feat(ecommerce,pay): 확장 결제수단(간편결제) 1급화

PG 플러그인이 등록하는 간편결제(네이버페이 등)를 코어가 정식 결제수단으로
인식하도록 결제수단 카탈로그를 SSoT 로 전환했다. 확장 결제수단이
pg_provider=null 로 등록돼 서버가 "PG 결제가 아닌 주문"으로 오인, 결제 실패
주문에 관리자 신규주문 알림이 발송되고 TempOrder 가 즉시 삭제돼 재결제가
막히던 두 결함을 하나의 원인(능력 해석을 enum 이 답하지 못함)으로 근본 해결.

- PaymentMethodResolver 신규: 능력(PG 필요/라벨/환불수단/PG 고정) 해석을
 카탈로그 SSoT 로 통일, enum case 없는 확장 ID 도 동일 판정
- 프론트 결제수단 위장(card) 제거, provider-agnostic 결제 진입(pg_payment_handler)
- 관리자 주문설정 결제수단 목록 PG 고정 3분기 UI (pg_locked/needs_pg)
- 저장된 pg_provider=null 자가치유(read-time merge) + 백필 업그레이드 스텝
- 사용자 주문완료 화면 즉시 결제완료 헤더를 입금대기 여집합으로 판정(basic 1.0.3)
- 전 계층 회귀 테스트: 훅 발화 스파이(관리자 알림 미발화/PG 환불 훅 진입),
 주문 응답 계약(requires_pg_payment/pg_payment_handler)+TempOrder 보존,
 관리자 3분기 렌더, 확장 E2E 인프라
This commit is contained in:
HeuJung
2026-07-15 11:23:51 +09:00
parent 10fe28a16d
commit e896a47d4f
107 changed files with 4002 additions and 1778 deletions
+16 -3
View File
@@ -74,14 +74,27 @@ test('@smoke 홈페이지 마운트', async ({ page }) => {
기존 PHPUnit testsuite (`Unit`/`Feature`/`Module`/`Plugin`) 및 Vitest 의 코어/확장 분리 원칙과 동일.
확장이 자체 config 를 가지므로 **확장 디렉토리에서 직접 실행**:
확장은 `tests/Playwright/playwright.config.ts` 에 자체 config 를 가지므로 **확장 디렉토리에서 직접 실행**한다.
config 가 확장 루트가 아니라 `tests/Playwright/` 아래에 있으므로 `npx playwright test` 를 인자 없이 부르면
config 를 찾지 못한다 — `npm run test:e2e` (config 경로가 이미 묶여 있음) 를 쓴다.
```powershell
cd modules/_bundled/sirsoft-ecommerce
$env:PLAYWRIGHT_BASE_URL='https://g7.dev'
npx playwright test
npm run test:e2e # 확장 전체
npm run test:e2e -- specs/admin/some.spec.ts # 단일 spec
npm run test:e2e:ui # UI 디버깅 모드
```
base URL 은 코어 `.env` 의 `APP_URL` 에서 자동 해석된다 (`PLAYWRIGHT_BASE_URL` 로 덮어쓸 수 있다).
### 실행 시 유의 (경험칙)
| 항목 | 내용 |
| --- | --- |
| 산출물 위치 | 리포트·trace 는 **코어 루트**의 `test-results/{type}/{id}/`, `playwright-report/{type}/{id}/` 에 쌓인다. 확장 디렉토리 안에 쌓으면 Windows 에서 `{module\|template}:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다 |
| 레이아웃 수정 후 | 레이아웃 JSON 을 고쳤으면 `{type}:update {id} --force` **+ `template:cache-clear {템플릿}`** 까지 해야 브라우저에 반영된다. 레이아웃은 템플릿 캐시(`/api/layouts/{template}/...`)로 서빙되므로 `cache:clear` 만으로는 갱신되지 않는다 |
| 공유 상태 | 같은 관리자 설정 화면을 건드리는 spec 이 병렬로 돌면 서로의 저장 상태를 덮어써 실패할 수 있다. 설정 저장을 수반하는 spec 은 `--workers=1` 로 확인한다 |
## §4. 데이터 생성 위치 — 책임 분리 매트릭스
| 데이터 종류 | 책임 영역 | 위치 | 호출 |
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.4] - 2026-07-14
### Added
- 주문설정 결제수단 목록의 "PG 고정" 표시에 대한 일본어 번역을 추가했습니다.
## [1.0.3] - 2026-07-14
### Added
@@ -151,7 +151,8 @@
"pg_not_installed": "PG未インストール",
"pg_select_first": "PG社をまず選択してください",
"mileage_deduction_timing": "マイレージ差し引き時点",
"mileage_deduction_timing_disabled_hint": "マイレージ使用がオフになっているため、差し引き時点が適用されません。"
"mileage_deduction_timing_disabled_hint": "マイレージ使用がオフになっているため、差し引き時点が適用されません。",
"pg_fixed": "PG 固定"
},
"bank_accounts": {
"title": "無通帳口座番号設定",
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
},
"version": "1.0.3",
"version": "1.0.4",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-ecommerce",
+3 -1
View File
@@ -9,7 +9,9 @@
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
"test:run": "vitest run",
"test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts",
"test:e2e:ui": "playwright test --config=tests/Playwright/playwright.config.ts --ui"
},
"devDependencies": {
"jsdom": "^27.4.0",
@@ -14,6 +14,18 @@ import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* 코어 루트 (artisan / .env / Playwright 산출물의 기준 경로).
*
* 확장 config 는 확장 디렉토리에서 실행되지만, 산출물을 그 안에 쓰면 Windows 에서
* `{module|template}:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다.
* 산출물은 코어 루트 아래로 모아 update 경로와 분리한다 (.gitignore 가 이미 덮는 위치).
*/
const CORE_ROOT = process.env.G7_ROOT || resolve(__dirname, '../../../../../');
/** 확장별 산출물 격리 — 확장끼리 리포트를 덮어쓰지 않도록 slug 로 네임스페이스. */
const ARTIFACT_SLUG = 'modules/sirsoft-board';
function readEnvFile(filePath: string, key: string): string | null {
if (!existsSync(filePath)) return null;
const content = readFileSync(filePath, { encoding: 'utf-8' });
@@ -31,8 +43,7 @@ function resolveBaseUrl(): string {
if (process.env.PLAYWRIGHT_BASE_URL) {
return process.env.PLAYWRIGHT_BASE_URL;
}
const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../');
const appUrl = readEnvFile(resolve(coreRoot, '.env'), 'APP_URL');
const appUrl = readEnvFile(resolve(CORE_ROOT, '.env'), 'APP_URL');
if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) {
return appUrl;
}
@@ -43,12 +54,13 @@ function resolveBaseUrl(): string {
export default defineConfig({
testDir: './specs',
outputDir: resolve(CORE_ROOT, 'test-results', ARTIFACT_SLUG),
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
['html', { outputFolder: resolve(CORE_ROOT, 'playwright-report', ARTIFACT_SLUG), open: 'never' }],
['list'],
],
use: {
@@ -4,6 +4,20 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.4] - 2026-07-14
### Fixed
- 네이버페이·카카오페이 등 결제 플러그인이 제공하는 간편결제로 주문할 때, 결제가 실패했는데도 관리자에게 "신규 주문 접수" 알림이 발송되던 문제를 수정했습니다. 이제 다른 카드 결제와 마찬가지로 결제가 실제로 완료된 시점에만 알림이 발송됩니다.
- 간편결제로 결제하다 실패한 뒤 다시 결제하려 하면 "임시주문을 찾을 수 없습니다" 오류가 뜨며 재결제가 불가능하던 문제를 수정했습니다. 이제 결제 실패 후에도 주문서가 유지되어 결제수단을 바꾸거나 다시 시도할 수 있습니다.
- 간편결제 주문을 취소할 때 카드 취소(PG 취소)가 함께 이뤄지지 않고 주문 상태만 취소로 바뀔 수 있던 문제를 수정했습니다.
- 주문 목록의 결제수단 필터와 주문서의 결제수단 검증이 간편결제를 인식하지 못하던 문제를 수정했습니다.
### Changed
- 결제 플러그인이 제공하는 간편결제도 신용카드·가상계좌와 동일하게 정식 결제수단으로 취급됩니다. 주문 내역에 실제로 선택한 결제수단(예: 네이버페이)이 그대로 기록되어 관리자 주문 상세와 주문 목록에서 확인할 수 있습니다.
- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 불필요"가 아니라 "PG 고정"으로 표시되고 어느 결제대행사를 통해 처리되는지 함께 보여줍니다. 간편결제는 해당 결제대행사 전용이므로 다른 대행사로 바꿀 수 없습니다.
## [1.0.3] - 2026-07-14
### Security
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-ecommerce",
"description": "Ecommerce module for Gnuboard7",
"type": "library",
"version": "1.0.3",
"version": "1.0.4",
"license": "MIT",
"autoload": {
"psr-4": {
@@ -31,7 +31,7 @@ class OrderPaymentFactory extends Factory
'embedded_pg_provider' => null,
'transaction_id' => $faker->uuid(),
'merchant_order_id' => 'MO-'.strtoupper($faker->bothify('????-####-????')),
'payment_method' => PaymentMethodEnum::CARD,
'payment_method' => PaymentMethodEnum::CARD->value,
'payment_device' => $faker->randomElement(['pc', 'mobile']),
'paid_amount_local' => $amount,
'paid_amount_base' => $amount,
@@ -130,7 +130,7 @@ class OrderPaymentFactory extends Factory
$faker = \fake();
return $this->state(fn (array $attributes) => [
'payment_method' => PaymentMethodEnum::CARD,
'payment_method' => PaymentMethodEnum::CARD->value,
'card_name' => $faker->randomElement(['신한카드', '삼성카드', 'KB국민카드', '현대카드']),
'card_number_masked' => $faker->numerify('####-****-****-####'),
]);
@@ -144,7 +144,7 @@ class OrderPaymentFactory extends Factory
$faker = \fake();
return $this->state(fn (array $attributes) => [
'payment_method' => PaymentMethodEnum::DBANK,
'payment_method' => PaymentMethodEnum::DBANK->value,
'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT,
'pg_provider' => '',
'embedded_pg_provider' => null,
@@ -169,7 +169,7 @@ class OrderPaymentFactory extends Factory
$faker = \fake();
return $this->state(fn (array $attributes) => [
'payment_method' => PaymentMethodEnum::VBANK,
'payment_method' => PaymentMethodEnum::VBANK->value,
'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT,
'vbank_code' => '088',
'vbank_name' => $faker->randomElement(['신한은행', '국민은행', '우리은행']),
@@ -35,7 +35,7 @@
| order_status | query | array | 아니오 | — | 주문상태 다중 선택 필터 (OrderStatusEnum 값 배열, 해당 상태의 주문만 조회) |
| option_status | query | array | 아니오 | — | 주문옵션 상태 다중 선택 필터 (OrderStatusEnum 값 배열, 해당 옵션 상태를 가진 주문만 조회) |
| shipping_type | query | array | 아니오 | — | 배송유형 다중 선택 필터 (ShippingType 코드 배열) |
| payment_method | query | array | 아니오 | — | 결제수단 다중 선택 필터 (PaymentMethodEnum 값 배열) |
| payment_method | query | array | 아니오 | 결제수단 카탈로그에 등록된 ID | 결제수단 다중 선택 필터. 코어 8종과 PG 플러그인이 등록한 확장 결제수단 ID(예: `nhnkcp_naverpay`)를 모두 허용 |
| category_id | query | integer | 아니오 | — | category 식별자 |
| min_amount | query | integer | 아니오 | min 0 | 주문금액 범위 필터 하한 (이 금액 이상 주문만 조회) |
| max_amount | query | integer | 아니오 | min 0 | 주문금액 범위 필터 상한 (이 금액 이하 주문만 조회) |
@@ -1330,7 +1330,7 @@ HTTP/1.1 200
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| payment_method | body | string | 예 | — | 결제수단 (PaymentMethodEnum 값 — card/vbank/dbank 등) |
| payment_method | body | string | 예 | 결제수단 카탈로그에 등록된 ID | 결제수단. 코어 8종(`card`/`vbank`/`dbank`/`bank`/`phone`/`point`/`deposit`/`free`) 과 PG 플러그인이 등록한 확장 결제수단 ID(예: `nhnkcp_naverpay`, `kginicis_lpay`)를 모두 허용한다. 확장 결제수단도 1급 시민으로 그대로 저장된다. 카탈로그에 없는 값은 422 |
| expected_total_amount | body | number | 예 | min 0 | 프론트가 계산한 예상 결제금액 (서버 재계산값과 대조해 금액 위변조 검증) |
| shipping_memo | body | string | 아니오 | max 500 | 배송 요청사항 메모 |
| depositor_name | body | string | 아니오 | max 50 | depositor 이름 (식별자) |
@@ -1362,18 +1362,33 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
| 이름 | 타입 | 조건 | 용도 |
| --- | --- | --- | --- |
| order | object | 항상 | 생성된 주문 리소스 |
| redirect_url | string | 항상 | 결제가 필요 없는 주문의 이동 대상 (주문완료 페이지) |
| requires_pg_payment | boolean | 항상 | PG 결제창을 거쳐야 하는 주문인지. 결제수단이 PG 를 요구하고(`needs_pg`), 해석된 PG 가 `manual`/`internal`/`none` 이 아니며, 잔여 결제액이 0보다 클 때 `true` |
| pg_provider | string | `requires_pg_payment=true` | 결제를 처리할 PG (`sirsoft-` 접두사 부착 — 예: `sirsoft-nhnkcp`) |
| pg_payment_data | object | `requires_pg_payment=true` | PG 결제창에 전달할 결제 정보 (아래 표) |
| pg_payment_handler | string | `requires_pg_payment=true` **이고** PG 가 결제 진입 핸들러를 선언한 경우 | 프론트가 그대로 dispatch 할 결제 진입 핸들러 풀네임 (예: `sirsoft-pay_nhnkcp.requestPayment`). PG 가 미선언이면 이 키가 없으며, 템플릿은 PG 분기를 발화하지 않고 비-PG 흐름으로 안전 강하한다 |
**응답 예시**
`pg_payment_data` 필드:
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
| 이름 | 타입 | 용도 |
| --- | --- | --- |
| order_number | string | 주문번호 |
| order_name | string | 주문명 (첫 상품명 + 외 N건) |
| amount | number | PG 청구액 (결제 통화의 최소 화폐단위 정수) |
| currency | string | 결제 통화 코드 |
| customer_name / customer_email / customer_phone | string\|null | 주문자 정보 |
| customer_key | string\|null | 회원 주문의 고객 식별자 (`user_{id}`), 비회원은 null |
| payment_method | string | 선택된 결제수단 ID. 확장 결제수단이면 확장 ID 그대로(예: `nhnkcp_naverpay`) — PG 플러그인이 이 값으로 결제창의 결제수단을 결정한다 |
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.create`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `payment_method` 가 결제수단 카탈로그에 없는 값이면 여기서 차단된다 |
<!-- @generated:end -->
@@ -3570,3 +3570,33 @@ _대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있
**설명** 인증 없이 접근 가능한 공개 엔드포인트로, 체크아웃에서 필요한 배송 설정을 반환합니다. `EcommerceSettingsService::getSettings('shipping')`로 기본 배송 국가·이용 가능한 국가 목록·국제 배송 활성화 여부·배송 타입·무료 배송 설정 등을 `shipping` 으로 내려줍니다. 프론트가 배송지 선택과 배송비 안내를 구성하는 데 사용하며, `logApiUsage('settings.shipping')`로 사용 로그를 남깁니다.
---
## 결제수단 카탈로그 (`order_settings.payment_methods[]`)
결제수단 카탈로그는 코어 8종(builtin)과 PG 플러그인이 훅으로 등록한 확장 결제수단을 함께 담는다.
설정 조회/저장 응답의 `order_settings.payment_methods` 배열 각 항목이 이 구조를 따른다.
아래 능력(capability) 필드는 **정의(builtin 은 코어, 확장은 플러그인 선언)가 SSoT** 이며 저장된 값으로
덮이지 않는다 — 관리자가 편집하는 값이 아니라 결제수단의 성격이기 때문이다.
| 이름 | 타입 | 용도 |
| --- | --- | --- |
| id | string | 결제수단 ID. builtin(`card` / `vbank` / `dbank` / `bank` / `phone` / `point` / `deposit` / `free`) 또는 확장 ID(예: `nhnkcp_naverpay`, `kginicis_lpay`) |
| needs_pg | boolean | PG 결제창이 필요한 수단인지. `false` 면 관리자 화면에 "PG 불필요" 로 표시되고 주문도 PG 를 거치지 않는다 (무통장·포인트·예치금·무료) |
| pg_locked | boolean | PG 가 특정 대행사로 고정된 수단인지. 간편결제처럼 특정 PG 전용인 수단은 `true` 이며, 관리자가 PG 를 바꿀 수 없고 화면에는 "PG 고정" 배지로 표시된다. `true` 일 때 `pg_provider` 는 저장값 대신 플러그인 선언값이 강제된다 |
| refund_method | string | 환불 수단 분류 (`pg` / `bank` / `points`). 주문 취소 시 PG 취소를 호출할지 결정한다 |
| pg_provider | string\|null | 이 결제수단을 처리할 PG. `pg_locked=true` 면 플러그인이 선언한 PG 로 고정되고, 아니면 관리자가 선택한다 (미선택 시 `default_pg_provider` 로 폴백) |
| is_active | boolean | 주문서에 노출할지 여부 |
| min_order_amount | number | 이 결제수단을 쓸 수 있는 최소 주문금액 |
| stock_deduction_timing | string | 재고 차감 시점 (`order_placed` / `payment_complete` / `none`) |
| mileage_deduction_timing | string | 마일리지 차감 시점 |
| sort_order | number | 주문서 노출 순서 |
| _cached_name / _cached_description | object | 다국어 표시명/설명 (locale => 문자열) |
| _cached_icon | string | 표시 아이콘 |
| _cached_source | string | 제공 주체 (`builtin` 또는 `plugin:{식별자}`) |
확장 결제수단(간편결제)은 코어의 `card` 로 치환되지 않고 자기 ID 그대로 저장·조회된다.
주문 생성 시 `payment_method` 로 확장 ID 를 그대로 보내면 되며, 서버는 이 카탈로그를 화이트리스트로
사용해 검증한다.
@@ -5,7 +5,7 @@
"ko": "이커머스",
"en": "Ecommerce"
},
"version": "1.0.3",
"version": "1.0.4",
"license": "MIT",
"description": {
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-ecommerce",
"version": "1.0.3",
"version": "1.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-ecommerce",
"version": "1.0.3",
"version": "1.0.4",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-ecommerce",
"version": "1.0.3",
"version": "1.0.4",
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -9,7 +9,9 @@
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
"test:run": "vitest run",
"test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts",
"test:e2e:ui": "playwright test --config=tests/Playwright/playwright.config.ts --ui"
},
"devDependencies": {
"jsdom": "^27.4.0",
@@ -422,6 +422,71 @@ describe('결제수단 Sortable 리스트 구조 검증 (_payment_methods_list.j
);
});
});
// ─── PG 능력 기반 3분기 (#475) ───
// 배경: 과거에는 PG 필요 여부를 하드코딩 배열(['point','deposit','free','dbank'])로
// 정규식 판정해 간편결제를 "PG 불필요"로 오분류했다. 이제 결제수단 카탈로그의
// pg_locked / needs_pg 능력으로 직접 3분기한다:
// ① pg_locked → "PG 고정 · {PG명}" 배지 (관리자 변경 불가)
// ② !pg_locked && needs_pg → PG 선택 셀렉트 (또는 미설치 안내)
// ③ !pg_locked && !needs_pg → "PG 불필요"
// 이 분기가 능력 대신 다시 하드코딩/orphaned 로 회귀하면 #475 재발이다.
describe('PG 능력 3분기 (#475)', () => {
const tpl = layout.itemTemplate;
const findByTestidExpr = (needle: string) =>
findFirst(tpl, (n: any) =>
typeof n?.props?.['data-testid'] === 'string'
&& n.props['data-testid'].includes(needle),
);
it('PG 고정 배지가 $method.pg_locked 조건으로 렌더된다', () => {
const badge = findByTestidExpr('pg-locked-badge-');
expect(badge).not.toBeNull();
expect(badge.if).toBe('{{$method.pg_locked}}');
// 배지 텍스트에 PG 표시명(available_pg_providers 의 name)이 들어가야
// "PG 불필요"와 구분된다.
expect(badge.text).toContain('available_pg_providers');
expect(badge.text).toContain('pg_provider');
});
it('PG 선택 셀렉트가 !pg_locked && needs_pg && 제공자>0 조건으로만 렌더된다', () => {
const select = findByTestidExpr('pg-select-');
expect(select).not.toBeNull();
expect(select.name).toBe('Select');
expect(select.if).toContain('!$method.pg_locked');
expect(select.if).toContain('$method.needs_pg');
expect(select.if).toContain('length > 0');
// 셀렉트 변경이 pg_provider 를 배열에 반영해야 한다.
expect(select.actions[0].handler).toBe('setState');
expect(select.actions[0].params['form.order_settings.payment_methods']).toContain(
'pg_provider',
);
});
it('"PG 불필요" 표시가 !pg_locked && !needs_pg 조건으로만 렌더된다', () => {
const notRequired = findByTestidExpr('pg-not-required-');
expect(notRequired).not.toBeNull();
expect(notRequired.if).toContain('!$method.pg_locked');
expect(notRequired.if).toContain('!$method.needs_pg');
expect(notRequired.text).toBe(
'$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_not_required',
);
});
it('세 분기가 상호배타적이다 (pg_locked 우선, 나머지는 !pg_locked)', () => {
const badge = findByTestidExpr('pg-locked-badge-');
const select = findByTestidExpr('pg-select-');
const notInstalled = findByTestidExpr('pg-not-installed-');
const notRequired = findByTestidExpr('pg-not-required-');
// pg_locked 분기만 pg_locked=true, 나머지 셋은 모두 !pg_locked 전제.
expect(badge.if).toBe('{{$method.pg_locked}}');
for (const branch of [select, notInstalled, notRequired]) {
expect(branch).not.toBeNull();
expect(branch.if).toContain('!$method.pg_locked');
}
});
});
});
// ─── _payment_methods_cards.json (모바일 Sortable) 구조 검증 ───
@@ -442,6 +507,42 @@ describe('결제수단 모바일 카드 구조 검증 (_payment_methods_cards.js
expect(layout.itemTemplate.classMap).toBeDefined();
expect(layout.itemTemplate.classMap.variants.orphaned).toBeDefined();
});
// ─── PG 능력 3분기 (#475) — 모바일 카드도 PC 리스트와 동일하게 판정해야 한다 ───
describe('PG 능력 3분기 (#475)', () => {
const tpl = layout.itemTemplate;
const findByTestidExpr = (needle: string) =>
findFirst(tpl, (n: any) =>
typeof n?.props?.['data-testid'] === 'string'
&& n.props['data-testid'].includes(needle),
);
it('PG 고정 배지가 $method.pg_locked 조건으로 렌더된다', () => {
const badge = findByTestidExpr('pg-locked-badge-');
expect(badge).not.toBeNull();
expect(badge.if).toBe('{{$method.pg_locked}}');
});
it('PG 선택 셀렉트가 !pg_locked && needs_pg 조건으로만 렌더된다', () => {
const select = findByTestidExpr('pg-select-');
expect(select).not.toBeNull();
expect(select.if).toContain('!$method.pg_locked');
expect(select.if).toContain('$method.needs_pg');
});
it('"PG 불필요" 표시가 !pg_locked && !needs_pg 조건으로만 렌더된다', () => {
const notRequired = findByTestidExpr('pg-not-required-');
expect(notRequired).not.toBeNull();
expect(notRequired.if).toContain('!$method.pg_locked');
expect(notRequired.if).toContain('!$method.needs_pg');
});
it('PC 리스트와 카드가 동일한 3개 data-testid 접두사를 쓴다 (표시 일관성)', () => {
for (const needle of ['pg-locked-badge-', 'pg-select-', 'pg-not-required-']) {
expect(findByTestidExpr(needle)).not.toBeNull();
}
});
});
});
// ─── _bank_accounts_table.json (PC 테이블) 구조 검증 ───
@@ -229,9 +229,11 @@ describe('admin_dashboard_commerce.json - commerce 슬롯 주입', () => {
});
it('최신 리뷰 항목(latest_review_item)에 iteration 이 정의된다', () => {
// iteration 은 리스트 컨테이너가 아니라 반복 항목 노드에 있고, 항목 id 는
// index_var 를 붙인 형태(latest_review_item_{{i}})다.
const list = findById(commerceExt as any, 'latest_reviews_list');
expect(list.iteration).toBeUndefined();
const item = findById(commerceExt as any, 'latest_review_item');
const item = findById(commerceExt as any, 'latest_review_item_{{i}}');
expect(item.iteration).toBeDefined();
expect(item.iteration.source).toBe('commerce_recent_reviews?.data');
expect(item.iteration.item_var).toBe('review');
@@ -241,7 +243,7 @@ describe('admin_dashboard_commerce.json - commerce 슬롯 주입', () => {
it('미답변 문의 항목(pending_inquiry_item)에 iteration 이 정의된다', () => {
const list = findById(commerceExt as any, 'pending_inquiries_list');
expect(list.iteration).toBeUndefined();
const item = findById(commerceExt as any, 'pending_inquiry_item');
const item = findById(commerceExt as any, 'pending_inquiry_item_{{i}}');
expect(item.iteration).toBeDefined();
expect(item.iteration.source).toBe('commerce_pending_inquiries?.data?.items');
expect(item.iteration.item_var).toBe('inquiry');
@@ -264,7 +266,7 @@ describe('admin_dashboard_commerce.json - commerce 슬롯 주입', () => {
// board_slug 미설정 시 링크 미노출
expect(viewAll.if).toBe('{{!!commerce_pending_inquiries?.data?.board_slug}}');
const item = findById(commerceExt as any, 'pending_inquiry_item');
const item = findById(commerceExt as any, 'pending_inquiry_item_{{i}}');
const click = item.actions.find((a: any) => a.type === 'click' && a.handler === 'navigate');
expect(click.params.path).toBe('/admin/board/{{commerce_pending_inquiries?.data?.board_slug}}/post/{{inquiry?.inquirable_id}}');
});
@@ -208,8 +208,11 @@ describe('주문 목록 partial 구조 검증 (_list.json)', () => {
expect(listJsonStr).not.toContain('order.multi_currency_total');
});
it('다통화 단가가 item.mc_unit_price를 사용해야 함', () => {
expect(listJsonStr).toContain('item.mc_unit_price');
it('단가가 item.unit_price_formatted를 사용해야 함', () => {
// 마이페이지 주문목록은 옵션 소계(mc_subtotal_price) 로 다통화를 표시하고
// 단가는 포맷 문자열(unit_price_formatted) 로만 노출한다. 단가 단위의
// 다통화 필드(mc_unit_price)는 이 레이아웃에 존재하지 않는다(주문완료 화면 전용).
expect(listJsonStr).toContain('item.unit_price_formatted');
expect(listJsonStr).not.toContain('item.multi_currency_unit_price');
});
@@ -225,8 +228,11 @@ describe('주문 목록 partial 구조 검증 (_list.json)', () => {
expect(listJsonStr).toContain('order.total_shipping_amount');
});
it('다통화 배송비가 order.mc_total_shipping_amount를 사용해야 함', () => {
expect(listJsonStr).toContain('order.mc_total_shipping_amount');
it('다통화 총액이 order.mc_total_amount를 사용해야 함', () => {
// 배송비 단위의 다통화 필드(mc_total_shipping_amount)는 없다. 다통화 금액은
// 주문 총액(mc_total_amount) 단위로만 표시된다(위 '다통화 금액' 테스트와 동일 근거).
expect(listJsonStr).toContain('order.mc_total_amount');
expect(listJsonStr).not.toContain('order.mc_total_shipping_amount');
});
});
@@ -150,6 +150,7 @@
"pg_use_default": "Default PG",
"pg_not_required": "Not required",
"pg_not_installed": "No PG",
"pg_fixed": "Fixed PG",
"pg_select_first": "Select PG first"
},
"bank_accounts": {
@@ -150,6 +150,7 @@
"pg_use_default": "기본 PG",
"pg_not_required": "PG 불필요",
"pg_not_installed": "PG 미설치",
"pg_fixed": "PG 고정",
"pg_select_first": "PG사를 먼저 선택하세요"
},
"bank_accounts": {
@@ -113,7 +113,7 @@
"if": "{{!$method._orphaned}}",
"props": {
"value": "{{$method.is_active}}",
"disabled": "{{_computed.isReadOnly || !['point','deposit','free','dbank'].includes($method.id) && !$method.pg_provider && !_local.form?.order_settings?.default_pg_provider}}"
"disabled": "{{_computed.isReadOnly || (!$method.pg_locked && $method.needs_pg && !$method.pg_provider && !_local.form?.order_settings?.default_pg_provider)}}"
},
"actions": [
{
@@ -191,11 +191,23 @@
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_provider"
},
{
"comment": "PG 고정 수단(간편결제 등) — 플러그인이 자기 PG 로 고정 선언했으므로 관리자가 바꿀 수 없다. PG 이름을 읽기전용으로 표시.",
"type": "basic",
"name": "Span",
"if": "{{$method.pg_locked}}",
"props": {
"data-testid": "{{'pg-locked-badge-' + $method.id}}",
"className": "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
},
"text": "{{$t('sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_fixed') + ' · ' + (((_local.form?.available_pg_providers ?? []).find(p => p.id === $method.pg_provider))?.name ? $localized(((_local.form?.available_pg_providers ?? []).find(p => p.id === $method.pg_provider)).name) : ($method.pg_provider ?? ''))}}"
},
{
"type": "basic",
"name": "Select",
"if": "{{!['point','deposit','free','dbank'].includes($method.id) && (_local.form?.available_pg_providers ?? []).length > 0}}",
"if": "{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length > 0}}",
"props": {
"data-testid": "{{'pg-select-' + $method.id}}",
"value": "{{$method.pg_provider ?? ''}}",
"className": "w-full",
"options": "{{[{value: '', label: '---'}].concat((_local.form?.available_pg_providers ?? []).filter(p => (p.supported_methods ?? []).includes($method.id)).map(p => ({value: p.id, label: $localized(p.name)})))}}",
@@ -216,8 +228,9 @@
{
"type": "basic",
"name": "Span",
"if": "{{!['point','deposit','free','dbank'].includes($method.id) && (_local.form?.available_pg_providers ?? []).length === 0}}",
"if": "{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length === 0}}",
"props": {
"data-testid": "{{'pg-not-installed-' + $method.id}}",
"className": "text-hint italic"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_not_installed"
@@ -225,8 +238,9 @@
{
"type": "basic",
"name": "Span",
"if": "{{['point','deposit','free','dbank'].includes($method.id)}}",
"if": "{{!$method.pg_locked && !$method.needs_pg}}",
"props": {
"data-testid": "{{'pg-not-required-' + $method.id}}",
"className": "text-hint"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_not_required"
@@ -126,11 +126,23 @@
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_provider"
},
{
"comment": "PG 고정 수단(간편결제 등) — 플러그인이 자기 PG 로 고정 선언했으므로 관리자가 바꿀 수 없다. PG 이름을 읽기전용으로 표시.",
"type": "basic",
"name": "Span",
"if": "{{$method.pg_locked}}",
"props": {
"data-testid": "{{'pg-locked-badge-' + $method.id}}",
"className": "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
},
"text": "{{$t('sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_fixed') + ' · ' + (((_local.form?.available_pg_providers ?? []).find(p => p.id === $method.pg_provider))?.name ? $localized(((_local.form?.available_pg_providers ?? []).find(p => p.id === $method.pg_provider)).name) : ($method.pg_provider ?? ''))}}"
},
{
"type": "basic",
"name": "Select",
"if": "{{!['point','deposit','free','dbank'].includes($method.id) && (_local.form?.available_pg_providers ?? []).length > 0}}",
"if": "{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length > 0}}",
"props": {
"data-testid": "{{'pg-select-' + $method.id}}",
"value": "{{$method.pg_provider ?? ''}}",
"className": "w-36",
"options": "{{[{value: '', label: '---'}].concat((_local.form?.available_pg_providers ?? []).filter(p => (p.supported_methods ?? []).includes($method.id)).map(p => ({value: p.id, label: $localized(p.name)})))}}",
@@ -151,8 +163,9 @@
{
"type": "basic",
"name": "Span",
"if": "{{!['point','deposit','free','dbank'].includes($method.id) && (_local.form?.available_pg_providers ?? []).length === 0}}",
"if": "{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length === 0}}",
"props": {
"data-testid": "{{'pg-not-installed-' + $method.id}}",
"className": "text-hint italic"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_not_installed"
@@ -160,8 +173,9 @@
{
"type": "basic",
"name": "Span",
"if": "{{['point','deposit','free','dbank'].includes($method.id)}}",
"if": "{{!$method.pg_locked && !$method.needs_pg}}",
"props": {
"data-testid": "{{'pg-not-required-' + $method.id}}",
"className": "text-hint"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_not_required"
@@ -317,7 +331,7 @@
"name": "Toggle",
"props": {
"value": "{{$method.is_active}}",
"disabled": "{{_computed.isReadOnly || !['point','deposit','free','dbank'].includes($method.id) && !$method.pg_provider && !_local.form?.order_settings?.default_pg_provider}}"
"disabled": "{{_computed.isReadOnly || (!$method.pg_locked && $method.needs_pg && !$method.pg_provider && !_local.form?.order_settings?.default_pg_provider)}}"
},
"actions": [
{
@@ -8,7 +8,6 @@ use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException;
use Modules\Sirsoft\Ecommerce\Exceptions\InsufficientStockException;
use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException;
@@ -96,10 +95,13 @@ trait HandlesOrderCreation
$order->load(['options', 'payment', 'shippingAddress']);
// PG 결제 필요 여부 판단
$paymentMethod = PaymentMethodEnum::tryFrom($order->payment->payment_method->value ?? $order->payment->payment_method);
$pgProvider = $this->orderProcessingService->determinePgProvider($paymentMethod->value);
$requiresPg = $paymentMethod->needsPgProvider()
// PG 결제 필요 여부 판단 — 확장 결제수단(간편결제)도 카탈로그 선언으로 올바르게 판정된다.
// enum 으로 판정하면 확장 ID 가 null → PG 불필요로 오인되어 관리자 알림이 오발송되고
// TempOrder 가 즉시 삭제되어 재결제가 막힌다(#475).
$pgProvider = $this->orderProcessingService->determinePgProvider(
$order->payment->paymentMethodId()
);
$requiresPg = $order->payment->needsPgProvider()
&& ! in_array($pgProvider, ['manual', 'internal', 'none'])
// 결제할 금액이 0원이면(전액 마일리지/예치금 등 비현금 충당) PG 호출 불필요 —
// 주문 생성 시점에 이미 결제완료 확정됨. 결제수단 선택과 무관.
@@ -305,6 +307,11 @@ trait HandlesOrderCreation
'customer_email' => $shippingAddress?->orderer_email,
'customer_phone' => preg_replace('/[^0-9]/', '', $shippingAddress?->orderer_phone ?? ''),
'customer_key' => $order->user_id ? "user_{$order->user_id}" : null,
// 선택된 결제수단 ID (확장 수단이면 확장 ID 그대로 — 예: 'kginicis_lpay').
// PG 플러그인의 결제 진입 핸들러가 이 값으로 결제창의 결제수단을 결정한다
// (예: kginicis_lpay → gopaymethod=LPAY). 서버가 확장 ID 를 1급 시민으로
// 저장하게 되면서 프론트 인터셉터가 원본 수단을 따로 전달할 필요가 없어졌다(#475).
'payment_method' => $order->payment?->paymentMethodId(),
];
}
}
@@ -6,7 +6,6 @@ use App\Helpers\ResponseHelper;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
/**
@@ -81,7 +80,7 @@ class ConfirmDepositRequest extends FormRequest
$order->loadMissing('payment');
// 무통장(dbank) 결제수단만 입금확인 대상
if ($order->payment?->payment_method !== PaymentMethodEnum::DBANK) {
if (! $order->payment?->isBankTransfer()) {
$validator->errors()->add(
'order',
__('sirsoft-ecommerce::messages.orders.deposit_not_dbank')
@@ -8,8 +8,8 @@ use Illuminate\Validation\Rule;
use Modules\Sirsoft\Ecommerce\Enums\DeviceTypeEnum;
use Modules\Sirsoft\Ecommerce\Enums\OrderDateTypeEnum;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Models\ShippingType;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
/**
* 주문 목록 조회 요청
@@ -19,7 +19,7 @@ class OrderListRequest extends FormRequest
/**
* 권한 확인
*
* @return bool
* @return bool 항상 true (권한 체크는 라우트의 permission 미들웨어에서 수행)
*/
public function authorize(): bool
{
@@ -29,7 +29,7 @@ class OrderListRequest extends FormRequest
/**
* 유효성 검사 규칙
*
* @return array
* @return array 주문 목록 조회 파라미터 검증 규칙
*/
public function rules(): array
{
@@ -57,7 +57,8 @@ class OrderListRequest extends FormRequest
// 결제수단 (다중선택)
'payment_method' => ['nullable', 'array'],
'payment_method.*' => ['string', Rule::in(PaymentMethodEnum::values())],
// 확장 결제수단(간편결제)도 주문 목록에서 필터링할 수 있어야 한다 (#475).
'payment_method.*' => ['string', Rule::in(app(PaymentMethodResolver::class)->allValidIds())],
// 카테고리
'category_id' => ['nullable', 'integer'],
@@ -5,10 +5,10 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin;
use App\Rules\LocaleRequiredTranslatable;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
/**
* 이커머스 설정 저장 요청 검증
@@ -525,13 +525,26 @@ class StoreEcommerceSettingsRequest extends FormRequest
return;
}
$resolver = app(PaymentMethodResolver::class);
foreach ($methods as $index => $method) {
$id = $method['id'] ?? '';
$isActive = $method['is_active'] ?? false;
$pgProvider = $method['pg_provider'] ?? null;
$enum = PaymentMethodEnum::tryFrom($id);
if ($enum && $enum->needsPgProvider() && $isActive && ! $pgProvider && ! $defaultPg) {
if ($id === '') {
continue;
}
// PG 고정 수단(간편결제 등)은 플러그인이 자기 PG 로 고정하므로 관리자가 선택할 수 없다.
// 검증 대상에서 제외하지 않으면 저장 자체가 막힌다.
if ($resolver->isPgLocked($id)) {
continue;
}
// 확장 결제수단도 카탈로그 선언으로 PG 필요 여부가 판정된다.
// enum tryFrom() 으로 판정하면 확장수단이 null → 검증이 통째로 스킵됐다(#475).
if ($resolver->needsPgProvider($id) && $isActive && ! $pgProvider && ! $defaultPg) {
$validator->errors()->add(
"order_settings.payment_methods.{$index}.pg_provider",
__('sirsoft-ecommerce::validation.custom.order_settings.payment_methods.pg_required_for_activation')
@@ -6,6 +6,7 @@ use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
/**
* 주문 생성 (결제하기) 요청
@@ -56,7 +57,14 @@ class CreateOrderRequest extends FormRequest
'shipping.intl_postal_code' => 'required_with:shipping.address_line_1|nullable|string|max:20',
// 결제 정보
'payment_method' => ['required', 'string', Rule::in(array_column(PaymentMethodEnum::cases(), 'value'))],
// 결제수단 화이트리스트는 카탈로그(builtin 8종 + 플러그인 등록 확장수단)가 SSoT.
// enum cases 로 제한하면 확장 결제수단(간편결제)이 422 로 막혀, 프론트가
// payment_method 를 'card' 로 위장해 보낼 수밖에 없게 된다(#475).
'payment_method' => [
'required',
'string',
Rule::in(app(PaymentMethodResolver::class)->allValidIds()),
],
'expected_total_amount' => 'required|numeric|min:0',
// 배송 메모
@@ -117,7 +117,7 @@ class OrderListResource extends BaseApiResource
// 결제
'payment' => $this->whenLoaded('payment', fn () => [
'payment_method' => $this->payment->payment_method,
'payment_method_label' => $this->payment->payment_method ? $this->payment->payment_method->label() : null,
'payment_method_label' => $this->payment->payment_method ? $this->payment->paymentMethodLabel() : null,
]),
// 배송 (첫 번째)
@@ -28,11 +28,11 @@ class OrderPaymentResource extends BaseApiResource
'payment_status_label' => $this->payment_status ? $this->payment_status->label() : null,
'payment_status_variant' => $this->payment_status ? $this->payment_status->variant() : null,
'payment_method' => $this->payment_method,
'payment_method_label' => $this->payment_method ? $this->payment_method->label() : null,
'payment_type_label' => $this->payment_method ? $this->payment_method->label() : null,
'payment_method_label' => $this->payment_method ? $this->paymentMethodLabel() : null,
'payment_type_label' => $this->payment_method ? $this->paymentMethodLabel() : null,
// PG 환불(취소)이 필요한 결제수단인지 — 취소 모달의 'PG 함께 취소' 체크박스·'환불 우선순위' 라디오
// 노출 여부 SSoT(무통장/포인트/예치금/무료는 false → 두 UI 숨김). U1·A28 공유 플래그.
'requires_pg_cancellation' => (bool) $this->payment_method?->needsPgProvider(),
'requires_pg_cancellation' => $this->payment_method ? $this->needsPgProvider() : false,
'pg_provider' => $this->pg_provider,
'transaction_id' => $this->transaction_id,
'merchant_order_id' => $this->merchant_order_id,
@@ -137,7 +137,7 @@ class OrderPaymentResource extends BaseApiResource
*/
private function getAccountInfo(): ?string
{
$method = $this->payment_method?->value ?? null;
$method = $this->paymentMethodId();
if ($method === PaymentMethodEnum::CARD->value) {
$info = $this->card_name ?? '';
@@ -404,7 +404,7 @@ class Order extends Model
*/
public function getPaymentMethodLabel(): ?string
{
return $this->payment?->payment_method?->getLabel();
return $this->payment?->paymentMethodLabel();
}
/**
@@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
/**
* 주문 결제 모델
@@ -106,12 +108,28 @@ class OrderPayment extends Model
'paid_at' => 'datetime',
'cancelled_at' => 'datetime',
'payment_status' => PaymentStatusEnum::class,
'payment_method' => PaymentMethodEnum::class,
// payment_method 는 의도적으로 캐스트하지 않는다 (순수 string) — 아래 주석 참조.
// 다중 통화 컬럼 (JSON)
'mc_paid_amount' => 'array',
'mc_cancelled_amount' => 'array',
];
/*
* payment_method 에 enum 캐스트를 두지 않는 이유
*
* PG 플러그인이 등록하는 확장 결제수단(예: 'nhnkcp_naverpay')은 PaymentMethodEnum 에
* case 가 없다. Laravel 의 enum 캐스트는 내부적으로 tryFrom() 이 아니라 from() 을 쓰므로
* (HasAttributes::getEnumCaseFromValue), 확장 ID 를 저장하는 순간 ValueError → 500 이 된다.
*
* 능력 질의(PG 필요 여부 / 라벨 / 환불수단)는 아래 능력 메서드가 PaymentMethodResolver 에
* 위임해 답한다 — 소비처는 payment_method 를 직접 비교하지 않는다.
*
* 하위호환: BackedEnum 은 JsonSerializable 이라 API 응답에는 이미 값 문자열('card')이
* 나가고 있었다. 순수 string 으로 바꿔도 wire format 이 완전히 동일하다.
*
* @see https://github.com/gnuboard/dev-g7/issues/475
*/
/**
* 주문 관계
*
@@ -139,7 +157,7 @@ class OrderPayment extends Model
*/
public function isCardPayment(): bool
{
return $this->payment_method === PaymentMethodEnum::CARD;
return $this->paymentMethodId() === PaymentMethodEnum::CARD->value;
}
/**
@@ -149,7 +167,78 @@ class OrderPayment extends Model
*/
public function isVirtualAccount(): bool
{
return $this->payment_method === PaymentMethodEnum::VBANK;
return $this->paymentMethodId() === PaymentMethodEnum::VBANK->value;
}
/**
* 무통장입금(수동 입금확인) 결제 여부 확인
*
* @return bool 무통장입금 여부
*/
public function isBankTransfer(): bool
{
return $this->paymentMethodId() === PaymentMethodEnum::DBANK->value;
}
/**
* PG 결제창이 필요한 결제수단인지 확인합니다.
*
* 확장 결제수단(간편결제 등)도 카탈로그 선언을 통해 올바르게 판정된다.
*
* @return bool PG 결제창 필요 여부
*/
public function needsPgProvider(): bool
{
return $this->resolver()->needsPgProvider($this->paymentMethodId());
}
/**
* 결제수단의 다국어 표시명을 반환합니다.
*
* @return string 표시명
*/
public function paymentMethodLabel(): string
{
return $this->resolver()->label($this->paymentMethodId());
}
/**
* 결제수단에 대응하는 환불 수단을 반환합니다.
*
* @return RefundMethodEnum 환불 수단
*/
public function refundMethod(): RefundMethodEnum
{
return $this->resolver()->refundMethod($this->paymentMethodId());
}
/**
* 결제수단 ID 를 문자열로 반환합니다.
*
* `payment_method` 는 순수 string 이지만, 과거 enum 캐스트 시절의 값이나 외부에서
* enum 을 대입한 경우를 방어적으로 흡수한다.
*
* @return string 결제수단 ID (미설정 시 빈 문자열)
*/
public function paymentMethodId(): string
{
$method = $this->payment_method;
if ($method instanceof \BackedEnum) {
return (string) $method->value;
}
return (string) ($method ?? '');
}
/**
* 결제수단 능력 해석기를 반환합니다.
*
* @return PaymentMethodResolver 결제수단 능력 해석기
*/
private function resolver(): PaymentMethodResolver
{
return app(PaymentMethodResolver::class);
}
/**
@@ -90,6 +90,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\UserAddressRepository;
use Modules\Sirsoft\Ecommerce\Seo\EcommerceSitemapContributor;
use Modules\Sirsoft\Ecommerce\Services\CategoryImageService;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
use Modules\Sirsoft\Ecommerce\Services\ProductImageService;
use Modules\Sirsoft\Ecommerce\Services\ProductReviewImageService;
use Modules\Sirsoft\Ecommerce\Services\ProductReviewService;
@@ -192,6 +193,9 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider
// ShippingPolicyResolver를 싱글톤으로 등록 (요청 내 기본 배송정책 조회 1회 캐시)
$this->app->singleton(ShippingPolicyResolver::class);
// PaymentMethodResolver를 싱글톤으로 등록 (요청 내 결제수단 카탈로그 조회 1회 캐시)
$this->app->singleton(PaymentMethodResolver::class);
}
/**
@@ -7,6 +7,8 @@ use App\Extension\HookManager;
use App\Traits\NormalizesSettingsData;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
/**
* 이커머스 모듈 환경설정 서비스
@@ -610,6 +612,12 @@ class EcommerceSettingsService implements ModuleSettingsInterface
'source' => $method['_cached_source'] ?? 'builtin',
'defaults' => [
'pg_provider' => $method['pg_provider'] ?? null,
// 능력(capability) 선언 — builtin 은 enum 에서 파생한다.
// 확장 결제수단은 PG 플러그인이 filter_available_payment_methods 훅에서
// 직접 선언한다 (enum case 가 없으므로).
'needs_pg' => $method['needs_pg'] ?? $this->builtinNeedsPg($id),
'pg_locked' => $method['pg_locked'] ?? false,
'refund_method' => $method['refund_method'] ?? $this->builtinRefundMethod($id),
'is_active' => $method['is_active'] ?? true,
'min_order_amount' => $method['min_order_amount'] ?? 0,
'stock_deduction_timing' => $method['stock_deduction_timing'] ?? 'payment_complete',
@@ -620,6 +628,35 @@ class EcommerceSettingsService implements ModuleSettingsInterface
}, $methods);
}
/**
* builtin 결제수단의 PG 필요 여부를 enum 에서 파생합니다.
*
* @param string $id 결제수단 ID
* @return bool PG 결제창 필요 여부
*/
private function builtinNeedsPg(string $id): bool
{
return PaymentMethodEnum::tryFrom($id)?->needsPgProvider() ?? true;
}
/**
* builtin 결제수단의 환불 수단을 enum 에서 파생합니다.
*
* @param string $id 결제수단 ID
* @return string 환불 수단 값 (pg / bank / points)
*/
private function builtinRefundMethod(string $id): string
{
return match (PaymentMethodEnum::tryFrom($id)) {
PaymentMethodEnum::CARD,
PaymentMethodEnum::BANK,
PaymentMethodEnum::VBANK,
PaymentMethodEnum::PHONE => RefundMethodEnum::PG->value,
PaymentMethodEnum::POINT => RefundMethodEnum::POINTS->value,
default => RefundMethodEnum::BANK->value,
};
}
/**
* 사용 가능한 결제수단 정의 조회 (기본 + 플러그인 필터)
*
@@ -744,10 +781,27 @@ class EcommerceSettingsService implements ModuleSettingsInterface
$id = $definition['id'];
$savedItem = $savedById->get($id);
// 능력(capability) 선언은 정의(enum 파생 또는 플러그인 선언)가 SSoT — saved 로 덮지 않는다.
// 관리자가 편집하는 값이 아니며, 플러그인이 자기 결제수단의 성격(PG 필요/환불수단/PG 고정)을
// 바꾸면 그 선언이 즉시 반영되어야 한다.
$needsPg = (bool) ($definition['defaults']['needs_pg'] ?? $this->builtinNeedsPg($id));
$pgLocked = (bool) ($definition['defaults']['pg_locked'] ?? false);
$refundMethod = $definition['defaults']['refund_method'] ?? $this->builtinRefundMethod($id);
// PG 고정 수단(간편결제 등)은 특정 PG 에 종속되므로 관리자가 다른 PG 로 바꿀 수 없다.
// saved 에 남은 과거 값(예: null)이 있어도 정의의 PG 를 강제한다.
$capabilities = [
'needs_pg' => $needsPg,
'pg_locked' => $pgLocked,
'refund_method' => $refundMethod,
];
if ($savedItem) {
$merged[] = [
$merged[] = array_merge([
'id' => $id,
'pg_provider' => $savedItem['pg_provider'] ?? null,
'pg_provider' => $pgLocked
? ($definition['defaults']['pg_provider'] ?? null)
: ($savedItem['pg_provider'] ?? null),
'sort_order' => $savedItem['sort_order'] ?? count($merged) + 1,
'is_active' => $savedItem['is_active'] ?? $definition['defaults']['is_active'] ?? true,
'min_order_amount' => $savedItem['min_order_amount'] ?? $definition['defaults']['min_order_amount'] ?? 0,
@@ -757,10 +811,10 @@ class EcommerceSettingsService implements ModuleSettingsInterface
'_cached_description' => $definition['description'] ?? ['ko' => '', 'en' => ''],
'_cached_icon' => $definition['icon'] ?? 'circle-question',
'_cached_source' => $definition['source'] ?? 'builtin',
];
], $capabilities);
} else {
// 신규 결제수단 (기본값 적용)
$merged[] = [
$merged[] = array_merge([
'id' => $id,
'pg_provider' => $definition['defaults']['pg_provider'] ?? null,
'sort_order' => count($merged) + 1,
@@ -772,7 +826,7 @@ class EcommerceSettingsService implements ModuleSettingsInterface
'_cached_description' => $definition['description'] ?? ['ko' => '', 'en' => ''],
'_cached_icon' => $definition['icon'] ?? 'circle-question',
'_cached_source' => $definition['source'] ?? 'builtin',
];
], $capabilities);
}
}
@@ -13,7 +13,6 @@ use Modules\Sirsoft\Ecommerce\Enums\CancelOptionStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\CancelStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\CancelTypeEnum;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundOptionStatusEnum;
@@ -278,7 +277,7 @@ class OrderCancellationService
// 무통장입금(dbank)·포인트·예치금·무료는 PG 리스너가 인식하지 못해 success=false → 트랜잭션 롤백.
// 이들은 PG 훅을 건너뛰고 운영자 수동 환불(APPROVED) 로 처리한다.
if ($cancelPg && $order->payment && $adjustmentResult->refundAmount > 0
&& $order->payment->payment_method?->needsPgProvider()) {
&& $order->payment->needsPgProvider()) {
$this->executePgRefund($order, $orderRefund, $adjustmentResult, $reason);
}
}
@@ -740,14 +739,9 @@ class OrderCancellationService
return RefundMethodEnum::BANK;
}
$paymentMethod = $order->payment->payment_method;
return match ($paymentMethod) {
PaymentMethodEnum::CARD, PaymentMethodEnum::BANK, PaymentMethodEnum::VBANK, PaymentMethodEnum::PHONE => RefundMethodEnum::PG,
PaymentMethodEnum::DBANK => RefundMethodEnum::BANK,
PaymentMethodEnum::POINT => RefundMethodEnum::POINTS,
default => RefundMethodEnum::BANK,
};
// 확장 결제수단(간편결제 등)도 카탈로그 선언을 통해 올바른 환불수단으로 해석된다.
// enum match 로 두면 확장 ID 가 default → BANK 로 떨어져 카드 취소가 누락된다(#475).
return $order->payment->refundMethod();
}
// ───────────────────────────────────────────────
@@ -237,15 +237,11 @@ class OrderProcessingService
*/
protected function orderRequiresPgPayment(Order $order): bool
{
$methodValue = $order->payment?->payment_method;
$methodValue = $methodValue instanceof \BackedEnum ? $methodValue->value : $methodValue;
$paymentMethod = PaymentMethodEnum::tryFrom((string) $methodValue);
if ($paymentMethod === null || ! $paymentMethod->needsPgProvider()) {
if (! $order->payment || ! $order->payment->needsPgProvider()) {
return false;
}
$pgProvider = $this->determinePgProvider($paymentMethod->value);
$pgProvider = $this->determinePgProvider($order->payment->paymentMethodId());
return ! in_array($pgProvider, ['manual', 'internal', 'none'], true)
&& (float) $order->total_due_amount > 0;
@@ -1495,7 +1491,7 @@ class OrderProcessingService
}
// 재고 차감 + 장바구니 처리 (payment_complete 타이밍: 트랜잭션 내부에서 실행)
$paymentMethodId = $order->payment->payment_method->value;
$paymentMethodId = $order->payment->paymentMethodId();
$timing = $this->settingsService->getStockDeductionTiming($paymentMethodId);
if ($timing === 'payment_complete') {
$this->stockService->deductStock($order->load('options'));
@@ -0,0 +1,214 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Services;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
/**
* 결제수단 능력(capability) 해석기
*
* "이 결제수단이 PG 를 쓰는가 / 라벨이 뭔가 / 환불수단이 뭔가" 를 답하는 단일 SSoT.
*
* 코어 8종(card/vbank/dbank/bank/phone/point/deposit/free)은 `PaymentMethodEnum` 이
* 정체성을 갖지만, PG 플러그인이 `filter_available_payment_methods` 훅으로 등록하는
* 간편결제 수단(예: `nhnkcp_naverpay`)은 enum case 가 없어 enum 메서드로는 능력을
* 물을 수 없다. 그래서 능력 질의를 **결제수단 설정 카탈로그**(`order_settings.payment_methods`)
* 기준으로 옮긴다 — 카탈로그는 builtin + 플러그인 등록분을 모두 포함하므로 확장 ID 도
* 자연스럽게 답이 나온다.
*
* 폴백 순서가 핵심이다:
*
* 카탈로그 선언 → enum(builtin) → 안전한 기본값
*
* 카탈로그에 선언이 없으면 enum 으로, enum 에도 없으면 기본값으로 내려간다. 그래서
* 플러그인이 신규 키를 선언하지 않아도 기존 동작이 유지되고(하위호환), 선언하면 그
* 값이 이긴다.
*
* 싱글톤으로 등록되어 요청 내 카탈로그 조회를 1회로 캐시한다.
*
* @see https://github.com/gnuboard/dev-g7/issues/475
*/
class PaymentMethodResolver
{
/**
* 요청 내 결제수단 카탈로그 캐시 (id => config). null = 아직 미조회
*
* @var array<string, array<string, mixed>>|null
*/
private ?array $catalogCache = null;
/**
* @param EcommerceSettingsService $settingsService 이커머스 설정 서비스
*/
public function __construct(
private EcommerceSettingsService $settingsService
) {}
/**
* PG 결제창이 필요한 결제수단인지 판정합니다.
*
* 폴백: 카탈로그 `needs_pg` → enum `needsPgProvider()` → true(확장 수단 기본).
*
* 미등록 확장 ID 의 기본값이 true 인 이유: 플러그인이 등록하는 결제수단은
* 대부분 PG 결제창을 거친다. false 로 오판하면 결제 실패 주문에 관리자 알림이
* 발송되고 TempOrder 가 삭제되어 재결제가 막히는(이슈 #475) 실패 모드가 된다.
* 반대 방향의 오판(PG 불필요 수단을 PG 로 봄)은 `determinePgProvider()` 가
* 'none'/'manual'/'internal' 을 반환해 걸러내므로 안전하다.
*
* @param string $methodId 결제수단 ID (builtin 8종 또는 확장 ID)
* @return bool PG 결제창 필요 여부
*/
public function needsPgProvider(string $methodId): bool
{
$declared = $this->catalogValue($methodId, 'needs_pg');
if ($declared !== null) {
return (bool) $declared;
}
return PaymentMethodEnum::tryFrom($methodId)?->needsPgProvider() ?? true;
}
/**
* 결제수단의 다국어 표시명을 반환합니다.
*
* 폴백: 카탈로그 `_cached_name` 의 현재 locale → enum `label()` → raw id.
*
* @param string $methodId 결제수단 ID
* @return string 현재 locale 의 표시명
*/
public function label(string $methodId): string
{
$name = $this->catalogValue($methodId, '_cached_name');
if (is_array($name)) {
$locale = app()->getLocale();
$resolved = $name[$locale]
?? $name[config('app.fallback_locale', 'ko')]
?? (! empty($name) ? array_values($name)[0] : null);
if (is_string($resolved) && $resolved !== '') {
return $resolved;
}
}
return PaymentMethodEnum::tryFrom($methodId)?->label() ?? $methodId;
}
/**
* 결제수단에 대응하는 환불 수단을 반환합니다.
*
* 폴백: 카탈로그 `refund_method` → enum 기반 분류 → BANK(기본).
*
* @param string $methodId 결제수단 ID
* @return RefundMethodEnum 환불 수단
*/
public function refundMethod(string $methodId): RefundMethodEnum
{
$declared = $this->catalogValue($methodId, 'refund_method');
if (is_string($declared) && ($enum = RefundMethodEnum::tryFrom($declared)) !== null) {
return $enum;
}
return match (PaymentMethodEnum::tryFrom($methodId)) {
PaymentMethodEnum::CARD,
PaymentMethodEnum::BANK,
PaymentMethodEnum::VBANK,
PaymentMethodEnum::PHONE => RefundMethodEnum::PG,
PaymentMethodEnum::POINT => RefundMethodEnum::POINTS,
PaymentMethodEnum::DBANK => RefundMethodEnum::BANK,
// 확장 결제수단(enum case 없음)은 PG 결제 여부로 판정한다.
// 미선언 확장수단이 BANK 로 떨어지면 카드 취소가 누락된다(안전 이슈).
default => $this->needsPgProvider($methodId)
? RefundMethodEnum::PG
: RefundMethodEnum::BANK,
};
}
/**
* 관리자가 PG 를 변경할 수 없는(플러그인이 자기 PG 로 고정한) 결제수단인지 판정합니다.
*
* 간편결제 수단(네이버페이 등)은 특정 PG 에 종속되므로 다른 PG 로 바꿀 수 없다.
* 관리자 주문설정 UI 가 PG 선택 Select 대신 "PG 고정" 배지를 표시하는 근거.
*
* @param string $methodId 결제수단 ID
* @return bool PG 고정 여부
*/
public function isPgLocked(string $methodId): bool
{
return (bool) $this->catalogValue($methodId, 'pg_locked');
}
/**
* 코어 builtin 8종 결제수단인지 판정합니다.
*
* @param string $methodId 결제수단 ID
* @return bool builtin 여부
*/
public function isBuiltin(string $methodId): bool
{
return PaymentMethodEnum::tryFrom($methodId) !== null;
}
/**
* 검증 화이트리스트 — builtin 8종 + 카탈로그에 등록된 확장 수단 전부.
*
* `CreateOrderRequest` / `OrderListRequest` 의 `Rule::in()` 에 사용한다.
* 확장 ID 를 1급 결제수단으로 받되, 카탈로그에 없는 임의 문자열은 여전히 거부한다.
*
* @return array<int, string> 유효한 결제수단 ID 목록
*/
public function allValidIds(): array
{
return array_values(array_unique(array_merge(
PaymentMethodEnum::values(),
array_keys($this->catalog())
)));
}
/**
* 카탈로그에서 특정 결제수단의 특정 키 값을 조회합니다.
*
* @param string $methodId 결제수단 ID
* @param string $key 조회할 키
* @return mixed 값 (미선언 시 null)
*/
private function catalogValue(string $methodId, string $key): mixed
{
return $this->catalog()[$methodId][$key] ?? null;
}
/**
* 결제수단 카탈로그를 ID 키 맵으로 반환합니다 (요청 내 1회 캐시).
*
* @return array<string, array<string, mixed>> 결제수단 ID => 설정
*/
private function catalog(): array
{
if ($this->catalogCache !== null) {
return $this->catalogCache;
}
$methods = $this->settingsService->getSetting('order_settings.payment_methods') ?? [];
$keyed = [];
foreach ($methods as $method) {
$id = $method['id'] ?? null;
if (is_string($id) && $id !== '') {
$keyed[$id] = $method;
}
}
return $this->catalogCache = $keyed;
}
/**
* 카탈로그 캐시를 비웁니다 (설정 저장 직후 재조회 강제).
*/
public function flushCache(): void
{
$this->catalogCache = null;
}
}
@@ -346,6 +346,90 @@ class OrderCancellationControllerTest extends ModuleTestCase
->assertJsonPath('success', true);
}
/**
* 확장 결제수단(간편결제) 주문을 취소하면 PG 환불 훅이 실제로 진입해야 한다 (#475).
*
* 취소 서비스는 `$order->payment->needsPgProvider()` 가 true 일 때만 PG 환불
* 훅(`payment.refund`)에 진입한다(OrderCancellationService:279-281). 확장 ID 는
* enum case 가 없어 과거에는 이 게이트에서 false 로 떨어져 카드 취소가 누락됐다.
* 이 테스트는 게이트 판정값이 아니라 실제 훅 발화를 스파이로 관측한다 — 판정과
* 훅 진입 사이 로직이 바뀌어 환불 누락이 재발하면 이 테스트가 잡는다.
*
* @scenario method_kind=extension, capability_declared=declared, capability=refund_method
*
* @effects refund_method_is_pg, pg_refund_hook_invoked
*/
#[Test]
public function admin_cancel_extension_payment_order_enters_pg_refund_hook(): void
{
$this->createCancelSequences();
// PG 플러그인이 하는 것과 동일하게 간편결제 수단을 카탈로그에 등록해
// needsPgProvider('nhnkcp_naverpay') 가 카탈로그 능력으로 true 가 되게 한다.
HookManager::addFilter(
'sirsoft-ecommerce.settings.filter_available_payment_methods',
fn (array $methods) => array_merge($methods, [[
'id' => 'nhnkcp_naverpay',
'name' => ['ko' => '네이버페이', 'en' => 'Naver Pay'],
'description' => ['ko' => '', 'en' => ''],
'icon' => 'credit-card',
'source' => 'plugin:sirsoft-pay_nhnkcp',
'defaults' => [
'pg_provider' => 'nhnkcp',
'pg_locked' => true,
'needs_pg' => true,
'refund_method' => 'pg',
'is_active' => true,
'min_order_amount' => 0,
'stock_deduction_timing' => 'payment_complete',
'mileage_deduction_timing' => 'payment_complete',
],
]])
);
app(\Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver::class)->flushCache();
$data = $this->createPaidOrderWithOptions(
optionCount: 1,
unitPrice: 30000,
quantity: 1,
withPayment: true,
);
$order = $data['order'];
// 결제 레코드를 확장 결제수단으로 지정 (기본 factory 는 card).
$order->payment()->update([
'payment_method' => 'nhnkcp_naverpay',
'pg_provider' => 'nhnkcp',
]);
// PG 환불 훅 진입 여부를 관측하는 스파이 (성공 응답도 시뮬레이션).
$refundHook = new \ArrayObject(['count' => 0]);
HookManager::addFilter('sirsoft-ecommerce.payment.refund', function ($default) use ($refundHook) {
$refundHook['count']++;
return [
'success' => true,
'transaction_id' => 'TEST_TXN_EXT',
'error_code' => null,
'error_message' => null,
];
}, 10);
$response = $this->actingAs($this->adminUser)
->postJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel", [
'type' => 'full',
'reason' => 'changed_mind',
'cancel_pg' => true,
]);
$response->assertOk()->assertJsonPath('success', true);
$this->assertSame(
1,
$refundHook['count'],
'확장 결제수단(간편결제) 취소 시 PG 환불 훅이 진입해야 한다 '
.'(미진입 시 카드 취소가 누락되는 #475 안전 결함 재발)'
);
}
/**
* cancel_pg=false로 취소 요청 시 PG 연동 없이 취소가 처리된다.
*/
@@ -2,6 +2,7 @@
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Public;
use App\Extension\HookManager;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
@@ -328,6 +329,120 @@ class OrderControllerTest extends ModuleTestCase
]);
}
/**
* PG 플러그인이 하는 것과 동일하게 간편결제 수단·PG provider 를 카탈로그에 등록합니다.
*
* 확장 결제수단(nhnkcp_naverpay) 이 능력(PG 필요/고정/핸들러)을 갖도록 하여
* 주문 생성 응답과 임시주문 보존이 실제 계약대로 동작하는지 검증할 수 있게 한다.
*/
private function registerExtensionEasyPayMethod(): void
{
HookManager::addFilter(
'sirsoft-ecommerce.settings.filter_available_payment_methods',
fn (array $methods) => array_merge($methods, [[
'id' => 'nhnkcp_naverpay',
'name' => ['ko' => '네이버페이', 'en' => 'Naver Pay'],
'description' => ['ko' => '', 'en' => ''],
'icon' => 'credit-card',
'source' => 'plugin:sirsoft-pay_nhnkcp',
'defaults' => [
'pg_provider' => 'nhnkcp',
'pg_locked' => true,
'needs_pg' => true,
'refund_method' => 'pg',
'is_active' => true,
'min_order_amount' => 0,
'stock_deduction_timing' => 'payment_complete',
'mileage_deduction_timing' => 'payment_complete',
],
]])
);
HookManager::addFilter(
'sirsoft-ecommerce.payment.registered_pg_providers',
fn (array $providers) => array_merge($providers, [[
'id' => 'nhnkcp',
'name' => 'NHN KCP',
'payment_handler' => 'sirsoft-pay_nhnkcp.requestPayment',
]])
);
app(\Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver::class)->flushCache();
app(\Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService::class)->clearCache();
}
/**
* T9 — 간편결제(확장 결제수단) 주문 생성 응답이 PG 결제 계약을 내려준다 (#475).
*
* 과거에는 확장 ID 가 PG 결제로 인식되지 않아 응답이 non-PG 로 내려갔고, 프론트가
* payment_method 를 'card' 로 위장해야 결제창이 떴다. 이제 응답이
* `requires_pg_payment=true` 와 provider 가 선언한 `pg_payment_handler` 를 실어
* 템플릿이 위장 없이 표준 dispatch 한다.
*
* @scenario method_kind=extension, capability_declared=declared, capability=needs_pg
*
* @effects requires_pg_payment_true, pg_payment_handler_present, extension_id_passes_validation
*/
public function test_간편결제_주문_생성_응답이_PG_결제_계약을_내려준다(): void
{
$this->registerExtensionEasyPayMethod();
$this->createGuestTempOrder();
$response = $this->postJson(
'/api/modules/sirsoft-ecommerce/user/orders',
$this->guestOrderPayload([
'payment_method' => 'nhnkcp_naverpay',
// 간편결제는 무통장 정보 불필요.
'depositor_name' => null,
'dbank' => null,
]),
['X-Cart-Key' => $this->cartKey]
);
$response->assertStatus(201)
->assertJsonPath('data.requires_pg_payment', true)
->assertJsonPath('data.pg_payment_handler', 'sirsoft-pay_nhnkcp.requestPayment');
// 저장된 결제수단이 확장 ID 그대로여야 한다 (card 위장 없음).
$order = Order::where('order_number', $response->json('data.order.order_number'))->first();
$this->assertSame('nhnkcp_naverpay', $order->payment->payment_method);
}
/**
* T10 — 간편결제 주문은 PG 결제 주문이므로 임시주문이 보존되어 재결제가 가능하다 (#475).
*
* 이슈의 핵심 증상: 결제 실패 후 재시도하면 "임시주문을 찾을 수 없습니다" 로 재결제가
* 막혔다. 원인은 확장 결제수단이 non-PG 로 오인되어 주문 생성 시점에 TempOrder 가
* 즉시 삭제된 것. 무통장(위 dbank 테스트)은 삭제가 정상이지만, PG 주문은 결제 완료
* 콜백 전까지 TempOrder 를 남겨 재결제 진입을 보장해야 한다.
*
* @scenario method_kind=extension, capability_declared=declared, capability=needs_pg
*
* @effects temp_order_preserved
*/
public function test_간편결제_주문_생성후_임시주문이_보존된다(): void
{
$this->registerExtensionEasyPayMethod();
$tempOrder = $this->createGuestTempOrder();
$response = $this->postJson(
'/api/modules/sirsoft-ecommerce/user/orders',
$this->guestOrderPayload([
'payment_method' => 'nhnkcp_naverpay',
'depositor_name' => null,
'dbank' => null,
]),
['X-Cart-Key' => $this->cartKey]
);
$response->assertStatus(201);
// 간편결제(PG)는 결제 전 pending → 임시주문이 살아 있어야 재결제가 가능하다.
$this->assertDatabaseHas('ecommerce_temp_orders', [
'id' => $tempOrder->id,
]);
}
/**
* 9. 비회원 주문 생성 시 조회 비밀번호가 해시로 저장된다 (평문 미저장)
*/
@@ -0,0 +1,554 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Payment;
use App\Extension\HookManager;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\TempOrderFactory;
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
use Modules\Sirsoft\Ecommerce\DTO\PromotionsSummary;
use Modules\Sirsoft\Ecommerce\DTO\Summary;
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\CreateOrderRequest;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
use Modules\Sirsoft\Ecommerce\Services\OrderCancellationService;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 확장 결제수단(간편결제) 1급화 회귀 테스트 — 이슈 #475
*
* PG 플러그인이 `filter_available_payment_methods` 로 등록하는 간편결제 수단
* (예: `nhnkcp_naverpay`) 은 코어 `PaymentMethodEnum` 에 case 가 없다. 이 때문에
* 서버가 해당 주문을 "PG 결제가 아닌 주문" 으로 오인하여 두 결함이 발생했다:
*
* 1. 결제 실패했는데 관리자에게 "신규 주문 접수" 알림이 발송됨
* 2. 결제 실패 후 TempOrder 가 즉시 삭제되어 재결제 불가 ("임시주문을 찾을 수 없습니다")
*
* 두 결함은 하나의 원인이다 — 확장 결제수단의 "능력(PG 필요 여부 / 라벨 / 환불수단)" 을
* enum 이 답하지 못한다. 본 테스트는 능력 해석이 결제수단 설정 카탈로그를 SSoT 로
* 삼도록 전환된 뒤 green 이 된다.
*
* @see https://github.com/gnuboard/dev-g7/issues/475
*/
class ExtensionPaymentMethodRegressionTest extends ModuleTestCase
{
/**
* PG 플러그인이 등록하는 확장 결제수단 ID (NHN KCP 네이버페이).
*/
private const EXT_METHOD = 'nhnkcp_naverpay';
/**
* 확장 결제수단이 소속된 PG.
*/
private const EXT_PG = 'nhnkcp';
protected function setUp(): void
{
parent::setUp();
$this->registerExtensionPaymentMethod();
}
protected function tearDown(): void
{
HookManager::resetAll();
parent::tearDown();
}
/**
* PG 플러그인이 하는 것과 동일하게 간편결제 수단을 카탈로그에 등록합니다.
*
* `RegisterEasyPayMethodsListener::buildEntry()` 가 내려주는 형태를 그대로 재현하되,
* 수정 후 선언될 능력 키(`pg_provider` / `needs_pg` / `refund_method` / `pg_locked`)를
* 포함한다 — 이 선언을 코어가 존중하는지가 본 회귀 테스트의 검증 대상이다.
*/
private function registerExtensionPaymentMethod(): void
{
HookManager::addFilter(
'sirsoft-ecommerce.settings.filter_available_payment_methods',
fn (array $methods) => array_merge($methods, [[
'id' => self::EXT_METHOD,
'name' => ['ko' => '네이버페이', 'en' => 'Naver Pay'],
'description' => ['ko' => '', 'en' => ''],
'icon' => 'credit-card',
'source' => 'plugin:sirsoft-pay_nhnkcp',
'defaults' => [
'pg_provider' => self::EXT_PG,
'pg_locked' => true,
'needs_pg' => true,
'refund_method' => 'pg',
'is_active' => true,
'min_order_amount' => 0,
'stock_deduction_timing' => 'payment_complete',
'mileage_deduction_timing' => 'payment_complete',
],
]])
);
HookManager::addFilter(
'sirsoft-ecommerce.payment.registered_pg_providers',
fn (array $providers) => array_merge($providers, [[
'id' => self::EXT_PG,
'name' => 'NHN KCP',
'payment_handler' => 'sirsoft-pay_nhnkcp.requestPayment',
]])
);
}
/**
* 확장 결제수단으로 결제 레코드를 만든 주문을 생성합니다.
*
* `payment_method` 컬럼에 확장 ID 를 1급 시민으로 저장한다 — 캐스트가 살아있는
* 현재는 Eloquent 의 enum 캐스트(`from()`) 가 ValueError 를 던진다.
*/
private function createExtensionPaymentOrder(array $paymentOverrides = []): OrderPayment
{
$order = OrderFactory::new()->create(['total_due_amount' => 50000]);
return OrderPaymentFactory::new()->forOrder($order)->create(array_merge([
'payment_method' => self::EXT_METHOD,
'pg_provider' => self::EXT_PG,
], $paymentOverrides));
}
/**
* 실제 주문 생성 흐름(createFromTempOrder)을 태우기 위한 최소 TempOrder 를 만듭니다.
*
* @param User $user 주문자
* @param int $finalAmount 최종 결제금액
* @return TempOrder 계산결과가 채워진 임시주문
*/
private function makeTempOrder(User $user, int $finalAmount = 50000): TempOrder
{
return TempOrderFactory::new()
->forUser($user)
->withCalculationResult([
'summary' => [
'subtotal' => $finalAmount,
'total_discount' => 0,
'product_coupon_discount' => 0,
'code_discount' => 0,
'total_shipping' => 0,
'final_amount' => $finalAmount,
'taxable_amount' => $finalAmount,
'tax_free_amount' => 0,
'points_used' => 0,
],
'items' => [],
'shippings' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
}
/**
* OrderCalculationService 를 mock 해 재계산 결과를 고정하고 서비스를 재바인딩합니다.
*
* createFromTempOrder 는 내부에서 재계산을 수행하므로, 저장금액과 동일한 결과를
* 주입해야 금액 불일치 예외 없이 주문 생성 경로를 끝까지 태울 수 있다.
*
* @param int $finalAmount 재계산 최종금액 (저장 금액과 동일해야 함)
* @return OrderProcessingService mock 이 주입된 서비스 인스턴스
*/
private function orderServiceWithFixedCalculation(int $finalAmount = 50000): OrderProcessingService
{
$summary = new Summary(
subtotal: $finalAmount,
totalDiscount: 0,
productCouponDiscount: 0,
codeDiscount: 0,
totalShipping: 0,
taxableAmount: $finalAmount,
taxFreeAmount: 0,
pointsUsed: 0,
pointsEarning: 0,
paymentAmount: $finalAmount,
finalAmount: $finalAmount,
);
$result = new OrderCalculationResult(
items: [],
summary: $summary,
promotions: new PromotionsSummary,
validationErrors: [],
);
$mock = $this->createMock(OrderCalculationService::class);
$mock->method('calculate')->willReturn($result);
$this->app->instance(OrderCalculationService::class, $mock);
return app(OrderProcessingService::class);
}
/**
* 지정 훅의 발화 횟수를 잡는 스파이를 부착하고, 라이브 카운터 객체를 돌려줍니다.
*
* Reflection 으로 가드 조건만 확인하면 호출지점 로직이 바뀌어도 회귀를 못 잡는다.
* 실제 주문 생성/취소 흐름에서 훅이 발화하는지를 직접 관측한다.
*
* 배열이 아닌 ArrayObject(참조형)를 돌려준다 — PHP 배열은 값 복사라
* `return $arr` 이후 클로저가 카운트를 올려도 호출자 쪽 사본은 갱신되지 않는다.
*
* @param string $hook 관측할 액션 훅 이름
* @return \ArrayObject 발화 횟수를 담는 라이브 카운터 (`$c['count']`)
*/
private function spyOnHook(string $hook): \ArrayObject
{
$counter = new \ArrayObject(['count' => 0]);
HookManager::addAction($hook, function () use ($counter): void {
$counter['count']++;
});
return $counter;
}
// ─────────────────────────────────────────────────────────────
// 6. 확장 ID 저장/조회 — 캐스트가 ValueError 를 던지지 않아야 한다
// ─────────────────────────────────────────────────────────────
/**
* @scenario method_kind=extension, capability_declared=declared, capability=needs_pg
*
* @effects extension_id_persisted_as_is
*/
#[Test]
public function extension_payment_method_id_can_be_persisted_and_read_back(): void
{
// 현재 red: 'payment_method' => PaymentMethodEnum::class 캐스트가 내부적으로
// from() 을 호출 → 확장 ID 는 enum case 가 없어 ValueError → 주문 생성 500.
$payment = $this->createExtensionPaymentOrder();
$this->assertSame(self::EXT_METHOD, $payment->fresh()->payment_method);
$this->assertDatabaseHas('ecommerce_order_payments', [
'id' => $payment->id,
'payment_method' => self::EXT_METHOD,
'pg_provider' => self::EXT_PG,
]);
}
// ─────────────────────────────────────────────────────────────
// 1. 관리자 신규주문 알림이 주문 생성 시점에 발화되지 않아야 한다
// ─────────────────────────────────────────────────────────────
/**
* @scenario method_kind=extension, capability_declared=declared, capability=needs_pg
*
* @effects requires_pg_payment_true, temp_order_preserved, admin_new_order_notification_deferred
*/
#[Test]
public function extension_payment_order_does_not_require_pg_is_false(): void
{
// orderRequiresPgPayment() 가 확장 결제수단을 PG 주문으로 인식해야 한다.
// 현재 red: PaymentMethodEnum::tryFrom('nhnkcp_naverpay') === null → false 반환
// → 주문 생성 즉시 order.after_admin_notify 발화 (관리자 548명 오발송).
$payment = $this->createExtensionPaymentOrder();
$order = $payment->order->load('payment');
$service = app(OrderProcessingService::class);
$method = new \ReflectionMethod($service, 'orderRequiresPgPayment');
$method->setAccessible(true);
$this->assertTrue(
$method->invoke($service, $order),
'확장 결제수단 주문은 PG 결제 주문으로 판정되어야 한다 '
.'(false 면 주문 생성 즉시 관리자 알림이 오발송된다)'
);
}
/**
* 실제 주문 생성 흐름에서 관리자 신규주문 알림 훅이 발화하지 않아야 한다.
*
* 위 Reflection 테스트가 판정 값만 본다면, 이 테스트는 createFromTempOrder 를
* 끝까지 태워 호출지점(OrderProcessingService:216-217)에서 실제로
* `order.after_admin_notify` 훅이 발화하는지를 스파이로 관측한다 — 판정과
* 호출지점 사이 로직이 바뀌어 오발송이 재발하면 이 테스트가 잡는다 (#475).
*
* @scenario method_kind=extension, capability_declared=declared, capability=needs_pg
*
* @effects admin_new_order_notification_deferred, temp_order_preserved, requires_pg_payment_true
*/
#[Test]
public function extension_payment_order_creation_does_not_fire_admin_notification(): void
{
$user = User::factory()->create();
$tempOrder = $this->makeTempOrder($user, 50000);
$service = $this->orderServiceWithFixedCalculation(50000);
$notify = $this->spyOnHook('sirsoft-ecommerce.order.after_admin_notify');
$order = $service->createFromTempOrder(
$tempOrder,
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'buyer@example.com'],
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구', 'address_detail' => '1동 2호'],
self::EXT_METHOD,
50000,
null,
);
// 확장 결제수단(PG 결제) 주문은 생성 시점(결제 전 pending)에서 알림을 미룬다.
$this->assertSame(
0,
$notify['count'],
'확장 결제수단(PG) 주문 생성 시 관리자 신규주문 알림이 발화되면 #475 오알림 재발이다',
);
// 주문·결제 레코드가 실제로 만들어졌는지(경로가 끝까지 탔는지) 확인.
$this->assertNotNull($order->payment);
$this->assertSame(self::EXT_METHOD, $order->payment->payment_method);
}
/**
* 대조군: 무통장입금(비-PG) 주문은 생성 시점에 관리자 알림이 발화되어야 한다.
*
* 스파이가 실제로 발화를 잡는지 검증하는 대조군이다 — 위 테스트가 항상 0 을
* 반환하는 거짓 통과가 아님을 보증한다(비-PG 는 접수 시점 발송이 정상 동작).
*
* @scenario method_kind=builtin, capability_declared=declared, capability=needs_pg
*
* @effects builtin_capability_unchanged
*/
#[Test]
public function builtin_bank_transfer_order_creation_fires_admin_notification(): void
{
$user = User::factory()->create();
$tempOrder = $this->makeTempOrder($user, 50000);
$service = $this->orderServiceWithFixedCalculation(50000);
$notify = $this->spyOnHook('sirsoft-ecommerce.order.after_admin_notify');
$order = $service->createFromTempOrder(
$tempOrder,
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'buyer@example.com'],
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구', 'address_detail' => '1동 2호'],
'dbank',
50000,
null,
);
$this->assertSame(
1,
$notify['count'],
'무통장입금(비-PG) 주문은 접수 시점에 관리자 알림이 발화되어야 한다 (스파이 동작 확인)',
);
}
// ─────────────────────────────────────────────────────────────
// 2. determinePgProvider 가 'none' 이 아닌 실제 PG 를 반환해야 한다
// (= TempOrder 보존 / requires_pg_payment=true 의 근거)
// ─────────────────────────────────────────────────────────────
/**
* @scenario method_kind=extension, capability_declared=declared, capability=needs_pg
*
* @effects pg_provider_resolved_not_none, temp_order_preserved
*/
#[Test]
public function extension_payment_method_resolves_to_its_own_pg_provider(): void
{
// 현재 red: 확장수단의 카탈로그 pg_provider 가 null 이면 default_pg_provider 로
// 폴백하고 그것도 null 이면 'none' → requiresPg=false → TempOrder 즉시 삭제.
$provider = app(OrderProcessingService::class)->determinePgProvider(self::EXT_METHOD);
$this->assertSame(self::EXT_PG, $provider);
$this->assertNotContains(
$provider,
['manual', 'internal', 'none'],
"확장 결제수단이 'none' 으로 해석되면 TempOrder 가 즉시 삭제되어 재결제가 불가능해진다"
);
}
// ─────────────────────────────────────────────────────────────
// 4. 취소 시 PG 환불 훅이 호출되어야 한다 (안전 이슈)
// ─────────────────────────────────────────────────────────────
/**
* @scenario method_kind=extension, capability_declared=declared, capability=refund_method
*
* @effects refund_method_is_pg, pg_refund_hook_invoked
*/
#[Test]
public function extension_payment_order_refund_method_is_pg(): void
{
// 현재 red: determineRefundMethod() 의 match 가 enum case 만 다뤄 default → BANK.
// PG 환불이 스킵되어 "취소는 됐는데 카드 취소가 안 되는" 실패가 발생한다.
$payment = $this->createExtensionPaymentOrder();
$order = $payment->order->load('payment');
$service = app(OrderCancellationService::class);
$method = new \ReflectionMethod($service, 'determineRefundMethod');
$method->setAccessible(true);
$this->assertSame(
RefundMethodEnum::PG,
$method->invoke($service, $order),
'확장 결제수단(PG 결제) 의 환불수단은 PG 여야 한다 (BANK 면 카드 취소가 누락된다)'
);
}
/**
* @scenario method_kind=extension, capability_declared=declared, capability=refund_method
*
* @effects pg_refund_hook_invoked
*/
#[Test]
public function extension_payment_needs_pg_provider_for_refund_hook(): void
{
// OrderCancellationService:281 의 PG 환불 훅 진입 가드.
// 현재 red: $payment->payment_method?->needsPgProvider() 가 확장 ID 에서 동작 불가.
$payment = $this->createExtensionPaymentOrder();
$this->assertTrue(
$payment->needsPgProvider(),
'PG 환불 훅 진입 가드가 확장 결제수단을 PG 결제로 인식해야 한다'
);
}
// ─────────────────────────────────────────────────────────────
// 5. 확장 ID 가 주문 생성 검증(CreateOrderRequest)을 통과해야 한다
// ─────────────────────────────────────────────────────────────
/**
* @scenario method_kind=extension, capability_declared=declared, capability=needs_pg
*
* @effects extension_id_passes_validation, pg_payment_handler_present
*/
#[Test]
public function extension_payment_method_id_passes_create_order_validation(): void
{
// 현재 red: Rule::in(PaymentMethodEnum::cases()) 가 확장 ID 를 422 로 막는다.
// 이것이 프론트 인터셉터가 payment_method 를 'card' 로 위장하게 만든 원인.
$rules = (new CreateOrderRequest)->rules();
$validator = Validator::make(
['payment_method' => self::EXT_METHOD],
['payment_method' => $rules['payment_method']]
);
$this->assertFalse(
$validator->errors()->has('payment_method'),
'확장 결제수단 ID 가 검증을 통과해야 한다 '
.'(422 로 막히면 프론트가 payment_method 를 card 로 위장할 수밖에 없다)'
);
}
/**
* @scenario method_kind=extension, capability_declared=undeclared, capability=needs_pg
*
* @effects unregistered_id_still_rejected
*/
#[Test]
public function unregistered_payment_method_id_is_still_rejected(): void
{
// 화이트리스트가 넓어지되 임의 문자열은 여전히 막아야 한다 (검증 우회 방지).
$rules = (new CreateOrderRequest)->rules();
$validator = Validator::make(
['payment_method' => 'totally_unknown_method'],
['payment_method' => $rules['payment_method']]
);
$this->assertTrue(
$validator->errors()->has('payment_method'),
'카탈로그에 등록되지 않은 결제수단은 여전히 거부되어야 한다'
);
}
// ─────────────────────────────────────────────────────────────
// 카탈로그 SSoT — 설정 병합이 확장수단의 PG 선언을 보존해야 한다
// ─────────────────────────────────────────────────────────────
/**
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
*
* @effects saved_null_pg_provider_self_healed, admin_shows_pg_locked_badge
*/
#[Test]
public function catalog_preserves_extension_method_pg_declaration(): void
{
// mergePaymentMethodSettings() 가 확장수단의 defaults(pg_provider/needs_pg 등) 를
// 병합 결과에 반영해야 한다. 현재 red: 신규 키 3종이 merge 대상이 아니다.
$config = app(EcommerceSettingsService::class)->getPaymentMethodConfig(self::EXT_METHOD);
$this->assertNotNull($config, '확장 결제수단이 카탈로그에 존재해야 한다');
$this->assertSame(self::EXT_PG, $config['pg_provider'] ?? null);
$this->assertTrue((bool) ($config['needs_pg'] ?? false));
$this->assertTrue((bool) ($config['pg_locked'] ?? false));
$this->assertSame('pg', $config['refund_method'] ?? null);
}
// ─────────────────────────────────────────────────────────────
// 하위호환 — builtin 8종의 능력 판정이 변하지 않아야 한다
// ─────────────────────────────────────────────────────────────
/**
* @scenario method_kind=builtin, capability_declared=declared, capability=needs_pg
*
* @effects builtin_capability_unchanged
*/
#[Test]
public function builtin_card_payment_still_requires_pg(): void
{
$payment = $this->createExtensionPaymentOrder([
'payment_method' => 'card',
'pg_provider' => 'inicis',
]);
$this->assertTrue($payment->needsPgProvider());
$this->assertTrue($payment->isCardPayment());
$this->assertFalse($payment->isVirtualAccount());
}
/**
* @scenario method_kind=builtin, capability_declared=declared, capability=needs_pg
*
* @effects builtin_capability_unchanged
*/
#[Test]
public function builtin_dbank_payment_still_does_not_require_pg(): void
{
// 무통장입금은 수동 입금확인 — PG 불필요. 캐스트 제거 후에도 유지되어야 한다.
// (ConfirmDepositRequest 의 입금확인 가드가 여기에 의존한다)
$payment = $this->createExtensionPaymentOrder([
'payment_method' => 'dbank',
'pg_provider' => '',
]);
$this->assertFalse($payment->needsPgProvider());
$this->assertTrue($payment->isBankTransfer());
$this->assertFalse($payment->isCardPayment());
}
/**
* @scenario method_kind=builtin, capability_declared=declared, capability=needs_pg
*
* @effects builtin_capability_unchanged
*/
#[Test]
public function builtin_vbank_payment_is_still_recognized_as_virtual_account(): void
{
// PG 플러그인의 가상계좌 분기(입금통보/환불)가 이 판정에 의존한다.
// 캐스트 제거로 이것이 조용히 false 가 되면 가상계좌 환불이 통째로 막힌다.
$payment = $this->createExtensionPaymentOrder([
'payment_method' => 'vbank',
'pg_provider' => 'nicepay',
]);
$this->assertTrue($payment->isVirtualAccount());
$this->assertTrue($payment->needsPgProvider());
$this->assertFalse($payment->isCardPayment());
}
}
@@ -25,6 +25,18 @@ import { fileURLToPath } from 'node:url';
// import.meta.url 로 재구성한다.
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* 코어 루트 (artisan / .env / Playwright 산출물의 기준 경로).
*
* 확장 config 는 확장 디렉토리에서 실행되지만, 산출물을 그 안에 쓰면 Windows 에서
* `{module|template}:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다.
* 산출물은 코어 루트 아래로 모아 update 경로와 분리한다 (.gitignore 가 이미 덮는 위치).
*/
const CORE_ROOT = process.env.G7_ROOT || resolve(__dirname, '../../../../../');
/** 확장별 산출물 격리 — 확장끼리 리포트를 덮어쓰지 않도록 slug 로 네임스페이스. */
const ARTIFACT_SLUG = 'modules/sirsoft-ecommerce';
function readEnvFile(filePath: string, key: string): string | null {
if (!existsSync(filePath)) return null;
const content = readFileSync(filePath, { encoding: 'utf-8' });
@@ -42,9 +54,7 @@ function resolveBaseUrl(): string {
if (process.env.PLAYWRIGHT_BASE_URL) {
return process.env.PLAYWRIGHT_BASE_URL;
}
// 모듈 디렉토리 기준 6단계 상위 = 코어 루트 (.env 위치)
const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../');
const appUrl = readEnvFile(resolve(coreRoot, '.env'), 'APP_URL');
const appUrl = readEnvFile(resolve(CORE_ROOT, '.env'), 'APP_URL');
if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) {
return appUrl;
}
@@ -55,12 +65,13 @@ function resolveBaseUrl(): string {
export default defineConfig({
testDir: './specs',
outputDir: resolve(CORE_ROOT, 'test-results', ARTIFACT_SLUG),
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
['html', { outputFolder: resolve(CORE_ROOT, 'playwright-report', ARTIFACT_SLUG), open: 'never' }],
['list'],
],
use: {
@@ -0,0 +1,208 @@
/**
* 주문설정 결제수단 — PG 고정(간편결제) 표시 회귀 (#475).
*
* 배경: PG 플러그인이 등록하는 간편결제(네이버페이 등)는 특정 PG 전용 수단인데,
* 과거에는 이를 코어의 "PG 불필요" 목록(`['point','deposit','free','dbank']`)에 정규식으로
* 끼워 넣어 PG 선택 셀렉트를 숨겼다. 화면에는 "PG 불필요" 로 보였고, 서버도 같은 오해를
* 공유해 간편결제 주문을 PG 결제가 아닌 주문으로 처리했다 — 그 결과 결제 실패 주문에
* 관리자 알림이 발송되고 임시주문이 삭제돼 재결제가 막혔다.
*
* 이제 결제수단 카탈로그가 `pg_locked` / `needs_pg` 를 내려주고 레이아웃이 그 값으로 직접
* 3분기한다: PG 고정 배지 / PG 선택 셀렉트 / "PG 불필요".
*
* 측정 규율 (이 spec 을 고칠 사람에게):
* - **결제수단 ID·PG명·라벨 문자열을 하드코딩하지 않는다.** 설치된 PG 플러그인 구성은
* 환경마다 다르다. 검증 대상 수단은 앱 상태의 카탈로그에서 `pg_locked` 플래그로 **런타임에
* 선별**하고, 기대 PG 표시명도 `available_pg_providers` 에서 도출한다.
* - PG 고정 수단이 하나도 없는 환경(간편결제 플러그인 미설치)에서는 판별이 불가능하므로
* skip 한다 — 거짓 통과를 만들지 않는다.
* - "셀렉트가 없다" 만으로는 회귀를 못 잡는다. 과거 결함도 셀렉트를 숨겼기 때문이다.
* **PG 고정 배지가 PG 이름과 함께 뜨는 것**까지 확인해야 "불필요" 와 "고정" 이 구분된다.
* - PG 선택 UI 는 네이티브 `<select>` 가 아니라 템플릿의 Select 컴포넌트(button 렌더)다.
* 태그로 찾지 말고 `data-testid` (`pg-select-{id}` / `pg-locked-badge-{id}` / `pg-not-required-{id}`)
* 로 분기 결과를 조회한다 — 컴포넌트 내부 구현이 바뀌어도 이 spec 은 유지된다.
*
* @scenario extension_payment_method method_kind=extension × capability_declared=declared × capability=pg_locked
* @effects admin_shows_pg_locked_badge,
* admin_hides_pg_select_for_locked,
* admin_shows_pg_select_for_unlocked,
* saved_null_pg_provider_self_healed
*/
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
import type { Page } from '@playwright/test';
const ORDER_SETTINGS_URL = '/admin/ecommerce/settings?tab=order_settings';
interface CatalogMethod {
id: string;
pg_provider: string | null;
pg_locked?: boolean;
needs_pg?: boolean;
}
/** 폼에 시드된 결제수단 카탈로그 (화면이 실제로 그리는 원본). */
async function catalogMethods(page: Page): Promise<CatalogMethod[]> {
return page.evaluate(() => {
const form = (window as any).G7Core?.state?.getLocal?.()?.form;
return (form?.order_settings?.payment_methods ?? []) as CatalogMethod[];
});
}
/** 설치된 PG 제공자 목록 (id → 표시명 도출용). */
async function pgProviders(page: Page): Promise<Array<{ id: string; name: unknown }>> {
return page.evaluate(() => (window as any).G7Core?.state?.getLocal?.()?.form?.available_pg_providers ?? []);
}
/**
* PG 제공자의 화면 표시명을 도출한다.
*
* `name` 은 활성 로케일로 이미 해석된 문자열이거나(현행), 로케일별 객체일 수 있다.
* 두 형태를 모두 받아 화면에 실제로 찍히는 문자열을 돌려준다 — 어느 쪽이든 spec 은 유지된다.
*
* @param provider available_pg_providers 항목 (미설치 PG 면 undefined)
* @param fallback 제공자를 못 찾았을 때 쓸 값 (결제수단의 pg_provider ID)
* @returns 배지에 표시될 것으로 기대되는 문자열
*/
function displayName(provider: { name?: unknown } | undefined, fallback: string | null): string {
const name = provider?.name;
if (typeof name === 'string' && name.trim()) return name;
if (name && typeof name === 'object') {
const localized = Object.values(name as Record<string, string>).find(Boolean);
if (localized) return localized;
}
return String(fallback ?? '');
}
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 });
// 결제수단 표가 시드될 때까지 대기 (_local.form 시드 → DOM 렌더)
await expect.poll(async () => (await catalogMethods(page)).length, { timeout: 30_000 }).toBeGreaterThan(0);
}
test.describe('@sirsoft-ecommerce 주문설정 결제수단 — PG 고정 표시', () => {
// @scenario extension_payment_method:pg_locked_badge
// @effects admin_shows_pg_locked_badge, admin_hides_pg_select_for_locked, saved_null_pg_provider_self_healed
test('PG 고정 수단(간편결제)은 PG 셀렉트 대신 "PG 고정 · {PG명}" 배지로 표시된다', async ({
page,
settingsToken,
}) => {
await gotoOrderSettings(page, settingsToken);
const methods = await catalogMethods(page);
const locked = methods.filter((m) => m.pg_locked === true);
test.skip(
locked.length === 0,
'PG 고정 결제수단이 없는 환경 (간편결제 플러그인 미설치) — 판별 불가',
);
// 자가 치유 검증: 저장 파일에 pg_provider=null 이 남아 있어도 정의값이 강제되어야 한다.
for (const method of locked) {
expect(
method.pg_provider,
`${method.id} 는 PG 고정 수단이므로 pg_provider 가 비어 있으면 안 된다 (저장된 null 이 정의값을 이기면 #475 재발)`,
).toBeTruthy();
expect(method.needs_pg, `${method.id} 는 PG 결제 수단이어야 한다`).toBe(true);
}
const providers = await pgProviders(page);
for (const method of locked) {
const providerName = displayName(
providers.find((p) => p.id === method.pg_provider),
method.pg_provider,
);
// ① PG 고정 배지가 뜨고, 그 안에 PG 이름이 들어 있어야 한다.
// ("PG 불필요" 로 표시되면 #475 회귀 — 배지 자체가 없으므로 아래 단언이 실패한다.)
const badge = page.getByTestId(`pg-locked-badge-${method.id}`);
await expect(
badge,
`${method.id} 는 PG 고정 수단이므로 PG 고정 배지가 떠야 한다`,
).toBeVisible();
await expect(
badge,
`${method.id} 의 배지에 PG 이름(${providerName})이 표시돼야 한다`,
).toContainText(String(providerName));
// ② PG 고정 수단에는 관리자가 PG 를 바꿀 수 있는 셀렉트가 없어야 한다.
await expect(
page.getByTestId(`pg-select-${method.id}`),
`${method.id} 는 PG 가 고정이므로 PG 선택 셀렉트가 뜨면 안 된다`,
).toHaveCount(0);
// ③ PG 고정과 "PG 불필요" 는 상호배타 — 둘 다 뜨면 표시가 모순된다.
await expect(
page.getByTestId(`pg-not-required-${method.id}`),
`${method.id} 가 "PG 불필요" 로 표시되면 #475 회귀`,
).toHaveCount(0);
}
});
// @scenario extension_payment_method:pg_select_for_unlocked
// @effects admin_shows_pg_select_for_unlocked
test('일반 PG 수단(카드 등)은 PG 선택 셀렉트가 그대로 노출된다 (회귀 차단)', async ({
page,
settingsToken,
}) => {
await gotoOrderSettings(page, settingsToken);
const methods = await catalogMethods(page);
const selectable = methods.filter((m) => !m.pg_locked && m.needs_pg);
test.skip(selectable.length === 0, 'PG 선택 가능한 결제수단이 없는 환경 — 판별 불가');
const providers = await pgProviders(page);
test.skip(providers.length === 0, '설치된 PG 플러그인이 없어 셀렉트가 렌더되지 않는 환경');
// PG 고정 분기가 과잉 적용돼 일반 PG 수단의 셀렉트까지 삼키는 회귀를 차단한다.
for (const method of selectable) {
await expect(
page.getByTestId(`pg-select-${method.id}`),
`${method.id} 는 관리자가 PG 를 고를 수 있어야 하므로 PG 선택 셀렉트가 떠야 한다`,
).toBeVisible({ timeout: 10_000 });
await expect(
page.getByTestId(`pg-locked-badge-${method.id}`),
`${method.id} 는 PG 고정이 아니므로 PG 고정 배지가 뜨면 안 된다`,
).toHaveCount(0);
}
});
// @scenario extension_payment_method:no_pg_required
// @effects admin_shows_pg_select_for_unlocked
test('PG 불필요 수단(무통장·포인트 등)은 셀렉트도 배지도 없이 "PG 불필요"로 남는다', async ({
page,
settingsToken,
}) => {
await gotoOrderSettings(page, settingsToken);
const methods = await catalogMethods(page);
const noPg = methods.filter((m) => m.needs_pg === false);
test.skip(noPg.length === 0, 'PG 불필요 결제수단이 없는 환경 — 판별 불가');
for (const method of noPg) {
// PG 불필요 수단은 PG 고정도 아니어야 한다 (두 분기가 겹치면 표시가 모순된다).
expect(method.pg_locked ?? false, `${method.id} 는 PG 불필요이므로 PG 고정일 수 없다`).toBe(false);
// 화면에도 "PG 불필요" 로만 남고, 셀렉트/배지 어느 쪽도 뜨지 않아야 한다.
await expect(
page.getByTestId(`pg-not-required-${method.id}`),
`${method.id} 는 "PG 불필요" 로 표시돼야 한다`,
).toBeVisible();
await expect(
page.getByTestId(`pg-select-${method.id}`),
`${method.id} 는 PG 가 필요 없으므로 PG 선택 셀렉트가 뜨면 안 된다`,
).toHaveCount(0);
await expect(
page.getByTestId(`pg-locked-badge-${method.id}`),
`${method.id} 는 PG 가 필요 없으므로 PG 고정 배지가 뜨면 안 된다`,
).toHaveCount(0);
}
});
});
@@ -164,7 +164,19 @@ test.describe('@sirsoft-ecommerce 배송국가 다국어', () => {
const locales = Object.keys(localeNames);
expect(locales.length, '설치된 언어를 못 읽었다').toBeGreaterThan(0);
await page.getByRole('button', { name: /Add Country|국가 추가|国追加/ }).click();
// "국가 추가" 버튼은 해외배송이 켜져 있을 때만 렌더된다
// (_tab_shipping.json: if "...international_shipping_enabled"). 시드 상태에서 꺼져
// 있을 수 있으므로, 다국어 입력칸 검증이 목적인 이 테스트에서는 해외배송을
// 로컬 상태로 켠 뒤 진행한다(토글 UI 자체 검증은 별도 spec 소관).
await page.evaluate(() => {
(window as any).G7Core?.state?.setLocal?.({ 'form.shipping.international_shipping_enabled': true });
});
const addCountryButton = page.getByRole('button', { name: /Add Country|국가 추가|国追加/ });
await expect(addCountryButton, '해외배송 활성 후 국가 추가 버튼이 노출돼야 한다').toBeVisible({
timeout: 10_000,
});
await addCountryButton.click();
// 국가 코드 입력칸(placeholder="KR")이 있는 행이 곧 추가 폼이다.
const codeInput = page.locator('input[placeholder="KR"]');
@@ -39,13 +39,28 @@ class OrderPaymentTest extends ModuleTestCase
$this->assertEquals($order->id, $payment->order->id);
}
public function test_order_payment_casts_method_to_enum(): void
public function test_order_payment_method_is_stored_as_plain_string(): void
{
// payment_method 는 enum 캐스트를 두지 않는다 (#475) — PG 플러그인이 등록하는
// 확장 결제수단(예: nhnkcp_naverpay)은 enum case 가 없어 캐스트가 ValueError 를 던지기 때문.
// 능력 질의는 모델의 능력 메서드가 PaymentMethodResolver 에 위임해 답한다.
$order = OrderFactory::new()->create();
$payment = OrderPaymentFactory::new()->forOrder($order)->card()->create();
$this->assertInstanceOf(PaymentMethodEnum::class, $payment->payment_method);
$this->assertEquals(PaymentMethodEnum::CARD, $payment->payment_method);
$this->assertSame(PaymentMethodEnum::CARD->value, $payment->fresh()->payment_method);
$this->assertTrue($payment->isCardPayment());
}
public function test_order_payment_accepts_extension_payment_method_id(): void
{
// 확장 결제수단 ID 를 1급 시민으로 저장할 수 있어야 한다 (캐스트가 있으면 ValueError).
$order = OrderFactory::new()->create();
$payment = OrderPaymentFactory::new()->forOrder($order)->create([
'payment_method' => 'nhnkcp_naverpay',
'pg_provider' => 'nhnkcp',
]);
$this->assertSame('nhnkcp_naverpay', $payment->fresh()->payment_method);
}
public function test_order_payment_casts_status_to_enum(): void
@@ -64,7 +79,7 @@ class OrderPaymentTest extends ModuleTestCase
'card_name' => '신한카드',
]);
$this->assertEquals(PaymentMethodEnum::CARD, $payment->payment_method);
$this->assertTrue($payment->isCardPayment());
$this->assertEquals('신한카드', $payment->card_name);
}
@@ -73,7 +88,7 @@ class OrderPaymentTest extends ModuleTestCase
$order = OrderFactory::new()->create();
$payment = OrderPaymentFactory::new()->forOrder($order)->virtualAccount()->create();
$this->assertEquals(PaymentMethodEnum::VBANK, $payment->payment_method);
$this->assertTrue($payment->isVirtualAccount());
$this->assertNotNull($payment->vbank_name);
$this->assertNotNull($payment->vbank_number);
}
@@ -0,0 +1,418 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Extension\HookManager;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
/**
* PaymentMethodResolver 단위 테스트 — 이슈 #475
*
* 결제수단 능력 해석의 폴백 3단계를 검증한다:
*
* 카탈로그 선언 → enum(builtin) → 안전한 기본값
*
* 폴백이 이 순서를 지켜야 (1) 플러그인이 신규 키를 선언하지 않아도 기존 동작이 유지되고
* (2) 선언하면 그 값이 이기며 (3) 미등록 확장 ID 도 안전한 쪽으로 판정된다.
*/
class PaymentMethodResolverTest extends ModuleTestCase
{
protected function tearDown(): void
{
HookManager::resetAll();
parent::tearDown();
}
private function resolver(): PaymentMethodResolver
{
return app(PaymentMethodResolver::class);
}
/**
* 확장 결제수단을 카탈로그에 등록합니다 (PG 플러그인이 하는 것과 동일).
*
* @param array<string, mixed> $defaults 선언할 능력 키
*/
private function registerMethod(string $id, array $defaults): void
{
HookManager::addFilter(
'sirsoft-ecommerce.settings.filter_available_payment_methods',
fn (array $methods) => array_merge($methods, [[
'id' => $id,
'name' => ['ko' => '확장수단', 'en' => 'Extension Method'],
'description' => ['ko' => '', 'en' => ''],
'icon' => 'credit-card',
'source' => 'plugin:test',
'defaults' => $defaults,
]])
);
// 카탈로그는 요청 내 1회 캐시되므로, 훅 등록 후 싱글톤을 새로 만들어야 한다.
$this->app->forgetInstance(PaymentMethodResolver::class);
}
// ─────────────────────────────────────────────
// needsPgProvider — 카탈로그 → enum → true
// ─────────────────────────────────────────────
/**
* @scenario method_kind=builtin, capability_declared=declared, capability=needs_pg
*
* @effects builtin_capability_unchanged
*/
#[Test]
public function catalog_declaration_wins_over_enum_for_needs_pg(): void
{
// dbank 는 enum 상 PG 불필요(false)지만, 카탈로그가 true 로 선언하면 그것이 이긴다.
$this->registerMethod('dbank', ['needs_pg' => true]);
$this->assertTrue($this->resolver()->needsPgProvider('dbank'));
}
/**
* @scenario method_kind=builtin, capability_declared=declared, capability=needs_pg
*
* @effects builtin_capability_unchanged
*/
#[Test]
#[DataProvider('builtinNeedsPgProvider')]
public function builtin_falls_back_to_enum_when_catalog_does_not_declare(string $methodId, bool $expected): void
{
// 카탈로그에 needs_pg 선언이 없으면 enum 의 판정을 그대로 따른다 (하위호환).
$this->assertSame($expected, $this->resolver()->needsPgProvider($methodId));
}
/**
* @return array<string, array{string, bool}>
*/
public static function builtinNeedsPgProvider(): array
{
return [
'card 는 PG 필요' => ['card', true],
'vbank 는 PG 필요' => ['vbank', true],
'bank 는 PG 필요' => ['bank', true],
'phone 은 PG 필요' => ['phone', true],
'dbank 는 수동 입금확인 — PG 불필요' => ['dbank', false],
'point 는 내부 처리 — PG 불필요' => ['point', false],
'deposit 은 내부 처리 — PG 불필요' => ['deposit', false],
'free 는 내부 처리 — PG 불필요' => ['free', false],
];
}
/**
* @scenario method_kind=extension, capability_declared=undeclared, capability=needs_pg
*
* @effects pg_provider_resolved_not_none
*/
#[Test]
public function unregistered_extension_id_defaults_to_requiring_pg(): void
{
// 미등록 확장 ID 는 안전한 쪽(PG 필요)으로 판정한다.
// false 로 오판하면 결제 실패 주문에 관리자 알림이 발송되고 TempOrder 가 삭제된다(#475).
$this->assertTrue($this->resolver()->needsPgProvider('some_unknown_plugin_method'));
}
/**
* @scenario method_kind=extension, capability_declared=declared, capability=needs_pg
*
* @effects pg_provider_resolved_not_none
*/
#[Test]
public function extension_method_declaring_needs_pg_false_is_respected(): void
{
// 확장이 명시적으로 PG 불필요를 선언하면 그것을 존중한다 (예: 후불 결제).
$this->registerMethod('vendor_deferred', ['needs_pg' => false]);
$this->assertFalse($this->resolver()->needsPgProvider('vendor_deferred'));
}
// ─────────────────────────────────────────────
// refundMethod — 카탈로그 → enum match → 능력 기반
// ─────────────────────────────────────────────
/**
* @scenario method_kind=builtin, capability_declared=declared, capability=refund_method
*
* @effects builtin_capability_unchanged
*/
#[Test]
#[DataProvider('builtinRefundMethodProvider')]
public function builtin_refund_method_matches_enum_classification(string $methodId, RefundMethodEnum $expected): void
{
$this->assertSame($expected, $this->resolver()->refundMethod($methodId));
}
/**
* @return array<string, array{string, RefundMethodEnum}>
*/
public static function builtinRefundMethodProvider(): array
{
return [
'card → PG 취소' => ['card', RefundMethodEnum::PG],
'vbank → PG 취소' => ['vbank', RefundMethodEnum::PG],
'bank → PG 취소' => ['bank', RefundMethodEnum::PG],
'phone → PG 취소' => ['phone', RefundMethodEnum::PG],
'dbank → 계좌 환불' => ['dbank', RefundMethodEnum::BANK],
'point → 포인트 환불' => ['point', RefundMethodEnum::POINTS],
];
}
/**
* @scenario method_kind=builtin, capability_declared=declared, capability=refund_method
*
* @effects builtin_capability_unchanged
*/
#[Test]
public function catalog_declaration_wins_over_enum_for_refund_method(): void
{
$this->registerMethod('card', ['refund_method' => 'bank']);
$this->assertSame(RefundMethodEnum::BANK, $this->resolver()->refundMethod('card'));
}
/**
* @scenario method_kind=extension, capability_declared=declared, capability=refund_method
*
* @effects refund_method_is_pg
*/
#[Test]
public function extension_method_declaring_pg_refund_is_respected(): void
{
$this->registerMethod('nhnkcp_naverpay', [
'needs_pg' => true,
'refund_method' => 'pg',
]);
$this->assertSame(RefundMethodEnum::PG, $this->resolver()->refundMethod('nhnkcp_naverpay'));
}
/**
* @scenario method_kind=extension, capability_declared=undeclared, capability=refund_method
*
* @effects refund_method_is_pg
*/
#[Test]
public function undeclared_extension_method_refund_follows_pg_capability(): void
{
// refund_method 를 선언하지 않은 확장수단은 PG 결제 여부로 환불수단을 정한다.
// BANK 로 떨어지면 카드 취소가 누락되는 안전 이슈가 된다.
$this->assertSame(
RefundMethodEnum::PG,
$this->resolver()->refundMethod('some_unknown_plugin_method')
);
}
/**
* @scenario method_kind=builtin, capability_declared=declared, capability=refund_method
*
* @effects builtin_capability_unchanged
*/
#[Test]
public function invalid_refund_method_declaration_falls_back_to_enum(): void
{
// 카탈로그에 유효하지 않은 값이 들어와도 폴백으로 안전하게 처리한다.
$this->registerMethod('card', ['refund_method' => 'not_a_real_method']);
$this->assertSame(RefundMethodEnum::PG, $this->resolver()->refundMethod('card'));
}
// ─────────────────────────────────────────────
// label — 카탈로그 다국어 → enum label → raw id
// ─────────────────────────────────────────────
/**
* @scenario method_kind=extension, capability_declared=declared, capability=label
*
* @effects extension_id_persisted_as_is
*/
#[Test]
public function extension_method_label_comes_from_catalog_in_current_locale(): void
{
$this->registerMethod('nhnkcp_naverpay', ['needs_pg' => true]);
app()->setLocale('ko');
$this->assertSame('확장수단', $this->resolver()->label('nhnkcp_naverpay'));
app()->setLocale('en');
$this->assertSame('Extension Method', $this->resolver()->label('nhnkcp_naverpay'));
}
/**
* @scenario method_kind=builtin, capability_declared=declared, capability=label
*
* @effects builtin_capability_unchanged
*/
#[Test]
public function builtin_label_falls_back_to_enum_label(): void
{
// builtin 은 enum 의 다국어 라벨을 그대로 쓴다.
$this->assertSame(
PaymentMethodEnum::CARD->label(),
$this->resolver()->label('card')
);
}
/**
* @scenario method_kind=extension, capability_declared=undeclared, capability=label
*
* @effects extension_id_persisted_as_is
*/
#[Test]
public function unknown_method_label_falls_back_to_raw_id(): void
{
// 최후 폴백: 키가 없으면 raw id (빈 문자열/예외 대신 식별 가능한 값).
$this->assertSame('mystery_method', $this->resolver()->label('mystery_method'));
}
// ─────────────────────────────────────────────
// isPgLocked / isBuiltin
// ─────────────────────────────────────────────
/**
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
*
* @effects admin_shows_pg_locked_badge, admin_hides_pg_select_for_locked
*/
#[Test]
public function extension_method_can_declare_pg_locked(): void
{
$this->registerMethod('nhnkcp_naverpay', [
'pg_provider' => 'nhnkcp',
'pg_locked' => true,
'needs_pg' => true,
]);
$this->assertTrue($this->resolver()->isPgLocked('nhnkcp_naverpay'));
}
/**
* @scenario method_kind=builtin, capability_declared=declared, capability=pg_locked
*
* @effects admin_shows_pg_select_for_unlocked
*/
#[Test]
public function builtin_methods_are_not_pg_locked(): void
{
// builtin 은 관리자가 PG 를 자유롭게 선택할 수 있어야 한다.
$this->assertFalse($this->resolver()->isPgLocked('card'));
$this->assertFalse($this->resolver()->isPgLocked('vbank'));
}
/**
* @scenario method_kind=extension, capability_declared=undeclared, capability=pg_locked
*
* @effects admin_shows_pg_select_for_unlocked
*/
#[Test]
public function is_builtin_distinguishes_core_methods_from_extension_ids(): void
{
$this->assertTrue($this->resolver()->isBuiltin('card'));
$this->assertTrue($this->resolver()->isBuiltin('dbank'));
$this->assertFalse($this->resolver()->isBuiltin('nhnkcp_naverpay'));
}
// ─────────────────────────────────────────────
// allValidIds — 검증 화이트리스트
// ─────────────────────────────────────────────
#[Test]
public function all_valid_ids_contains_every_builtin_method(): void
{
$ids = $this->resolver()->allValidIds();
foreach (PaymentMethodEnum::values() as $builtin) {
$this->assertContains($builtin, $ids);
}
}
/**
* @scenario method_kind=extension, capability_declared=declared, capability=needs_pg
*
* @effects extension_id_passes_validation
*/
#[Test]
public function all_valid_ids_includes_registered_extension_methods(): void
{
$this->registerMethod('nhnkcp_naverpay', ['needs_pg' => true]);
$ids = $this->resolver()->allValidIds();
$this->assertContains('nhnkcp_naverpay', $ids);
$this->assertContains('card', $ids, 'builtin 이 확장 등록으로 밀려나면 안 된다');
}
/**
* @scenario method_kind=extension, capability_declared=undeclared, capability=needs_pg
*
* @effects unregistered_id_still_rejected
*/
#[Test]
public function all_valid_ids_excludes_unregistered_ids(): void
{
// 화이트리스트가 넓어지되 임의 문자열은 여전히 배제해야 한다 (검증 우회 방지).
$this->assertNotContains('totally_unknown_method', $this->resolver()->allValidIds());
}
#[Test]
public function all_valid_ids_has_no_duplicates(): void
{
// 확장이 builtin id 를 덮어쓰는 형태로 등록해도 중복이 생기지 않아야 한다.
$this->registerMethod('card', ['needs_pg' => true]);
$ids = $this->resolver()->allValidIds();
$this->assertSame(count($ids), count(array_unique($ids)));
}
// ─────────────────────────────────────────────
// 기존 설치 환경 자가 치유 — saved 설정이 정의를 이기지 못한다
// ─────────────────────────────────────────────
/**
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
*
* @effects saved_null_pg_provider_self_healed
*/
#[Test]
public function saved_null_pg_provider_does_not_override_pg_locked_declaration(): void
{
// 기존 설치 환경의 order_settings.json 에는 확장 결제수단이 이미
// `pg_provider: null` 로 영속화되어 있다(#475 결함 시절의 값).
//
// mergePaymentMethodSettings() 가 saved 우선이면 플러그인이 defaults 를 고쳐도
// 기존 설정 파일이 이겨서 결함이 그대로 남는다. 능력 선언(pg_locked/needs_pg/
// refund_method)과 PG 고정 수단의 pg_provider 는 정의가 SSoT 여야 한다.
$this->registerMethod('nhnkcp_naverpay', [
'pg_provider' => 'nhnkcp',
'pg_locked' => true,
'needs_pg' => true,
'refund_method' => 'pg',
]);
$settings = app(EcommerceSettingsService::class);
// 결함 시절의 saved 상태를 재현 — pg_provider 가 null 로 저장되어 있다.
$settings->saveSettings([
'order_settings' => [
'payment_methods' => [
['id' => 'nhnkcp_naverpay', 'pg_provider' => null, 'is_active' => true],
],
],
]);
// 싱글톤 캐시를 비우고 재조회 (저장 후 읽기 경로)
$this->app->forgetInstance(PaymentMethodResolver::class);
$config = $settings->getPaymentMethodConfig('nhnkcp_naverpay');
$this->assertSame('nhnkcp', $config['pg_provider'] ?? null, 'PG 고정 수단은 saved null 을 무시하고 정의의 PG 를 강제해야 한다');
$this->assertTrue((bool) ($config['needs_pg'] ?? false));
$this->assertTrue($this->resolver()->needsPgProvider('nhnkcp_naverpay'));
}
}
@@ -0,0 +1,69 @@
feature: extension_payment_method
description: >
PG 플러그인이 등록하는 확장 결제수단(간편결제)을 1급 시민으로 처리한다.
능력(PG 필요 여부 / 라벨 / 환불수단 / PG 고정)의 해석은 결제수단 설정 카탈로그를 SSoT 로
삼으며, 코어 PaymentMethodEnum 에 case 가 없는 확장 ID 도 동일하게 판정된다.
이슈 #475: 확장 결제수단이 pg_provider=null 로 등록돼 서버가 "PG 결제가 아닌 주문" 으로
오인했고, 그 결과 (a) 결제 실패한 주문에 관리자 신규주문 알림이 발송되고 (b) 임시주문이
즉시 삭제돼 재결제가 불가능했다. 두 결함은 하나의 원인이다.
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Payment/ExtensionPaymentMethodRegressionTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/OrderCancellationControllerTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/PaymentMethodResolverTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/OrderPaymentTest.php
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/payment-method-pg-locked.spec.ts
- plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php
- plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Upgrades/BackfillEasyPayPgProviderTest.php
- plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php
- 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
# 입력 axis — 능력 해석에 영향을 주는 변수
axes:
# 결제수단의 출처: 코어 8종 vs PG 플러그인이 등록한 확장 ID
method_kind: [builtin, extension]
# 카탈로그가 능력을 선언했는가 (플러그인이 신규 키를 안 쓰는 하위호환 경로)
capability_declared: [declared, undeclared]
# 능력별 판정 표면
capability: [needs_pg, label, refund_method, pg_locked]
exclusions:
- method_kind: builtin
capability_declared: undeclared
reason: "builtin 은 코어가 enum 에서 능력을 항상 파생하므로 '미선언' 상태가 존재하지 않는다"
# 후속 효과 체인 — 능력 오판이 낳는 실패들
effects:
# 저장/조회
- extension_id_persisted_as_is # payment_method 컬럼에 확장 ID 그대로 (enum 캐스트 ValueError 없음)
- builtin_capability_unchanged # 코어 8종 판정 불변 (무통장 입금확인 · 가상계좌 환불 회귀 차단)
# 주문 생성
- extension_id_passes_validation # 확장 ID 가 422 로 막히지 않음 (프론트 위장 불필요)
- unregistered_id_still_rejected # 카탈로그 미등록 값은 여전히 차단 (검증 우회 방지)
- pg_provider_resolved_not_none # determinePgProvider 가 'none' 이 아닌 실제 PG 반환
- requires_pg_payment_true # 주문 응답의 requires_pg_payment=true
- pg_payment_handler_present # provider 선언 핸들러가 응답에 실려 템플릿이 dispatch
- temp_order_preserved # PG 결제 주문이므로 임시주문 미삭제 → 재결제 가능
- admin_new_order_notification_deferred # 관리자 신규주문 알림이 주문 생성 시점에 발화하지 않음
# 취소/환불
- refund_method_is_pg # 환불수단 PG (BANK 로 떨어지면 카드 취소 누락)
- pg_refund_hook_invoked # 취소 시 PG 환불 훅 진입
# 관리자 화면
- admin_shows_pg_locked_badge # 확장 결제수단이 "PG 고정 · {PG명}" 배지로 표시
- admin_hides_pg_select_for_locked # PG 고정 수단은 PG 선택 셀렉트 미노출
- admin_shows_pg_select_for_unlocked # 일반 PG 수단은 셀렉트 노출 (회귀 차단)
# 기존 설치 환경
- saved_null_pg_provider_self_healed # 저장된 pg_provider=null 이 정의값으로 덮임
- upgrade_step_backfills_settings_file # upgrade step 이 저장 파일 자체도 정정 (멱등)
sub_flows:
- id: legacy_card_orders
description: 기존에 payment_method='card' 로 저장된 간편결제 주문 (백필하지 않음)
effects:
- builtin_capability_unchanged
- refund_method_is_pg
@@ -1,15 +1,15 @@
{
"schema_version": "1.0",
"generated_at": "2026-07-10T20:22:38+00:00",
"generated_at": "2026-07-14T14:08:36+00:00",
"generator": "g7 vendor-bundle:build",
"target": "module:sirsoft-ecommerce",
"composer_json_sha256": "5195d789bc9edd0b5140c42aeedd7c87be67c7252b477a879ff90607b4787935",
"composer_json_sha256": "a90f1bb9cceeb9542e84ca09a0945f31089879fdbb566044364058332627998a",
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
"zip_sha256": "c1767e8eca61163667e9289a3963454bab92806ce4e8a3651777f1e1aea14081",
"zip_sha256": "ffc7123cd61c7ca13a6dc29325aa31454005affd59fe97acf372d30ab016475d",
"zip_size": 435549,
"package_count": 1,
"php_requirement": "^8.2",
"g7_version": "7.0.3",
"g7_version": "7.0.4",
"packages": [
{
"name": "ezyang/htmlpurifier",
Binary file not shown.
+3 -1
View File
@@ -9,7 +9,9 @@
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
"test:run": "vitest run",
"test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts",
"test:e2e:ui": "playwright test --config=tests/Playwright/playwright.config.ts --ui"
},
"devDependencies": {
"jsdom": "^27.4.0",
@@ -25,6 +25,18 @@ import { fileURLToPath } from 'node:url';
// import.meta.url 로 재구성한다.
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* 코어 루트 (artisan / .env / Playwright 산출물의 기준 경로).
*
* 확장 config 는 확장 디렉토리에서 실행되지만, 산출물을 그 안에 쓰면 Windows 에서
* `{module|template}:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다.
* 산출물은 코어 루트 아래로 모아 update 경로와 분리한다 (.gitignore 가 이미 덮는 위치).
*/
const CORE_ROOT = process.env.G7_ROOT || resolve(__dirname, '../../../../../');
/** 확장별 산출물 격리 — 확장끼리 리포트를 덮어쓰지 않도록 slug 로 네임스페이스. */
const ARTIFACT_SLUG = 'modules/sirsoft-page';
function readEnvFile(filePath: string, key: string): string | null {
if (!existsSync(filePath)) return null;
const content = readFileSync(filePath, { encoding: 'utf-8' });
@@ -42,10 +54,7 @@ function resolveBaseUrl(): string {
if (process.env.PLAYWRIGHT_BASE_URL) {
return process.env.PLAYWRIGHT_BASE_URL;
}
// 모듈 디렉토리 기준 5단계 상위 = 코어 루트 (.env 위치)
// (modules/_bundled/sirsoft-page/tests/Playwright → 코어 루트)
const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../');
const appUrl = readEnvFile(resolve(coreRoot, '.env'), 'APP_URL');
const appUrl = readEnvFile(resolve(CORE_ROOT, '.env'), 'APP_URL');
if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) {
return appUrl;
}
@@ -56,12 +65,13 @@ function resolveBaseUrl(): string {
export default defineConfig({
testDir: './specs',
outputDir: resolve(CORE_ROOT, 'test-results', ARTIFACT_SLUG),
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
['html', { outputFolder: resolve(CORE_ROOT, 'playwright-report', ARTIFACT_SLUG), open: 'never' }],
['list'],
],
use: {
@@ -4,6 +4,20 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-14
### Fixed
- 삼성페이·네이버페이·카카오페이·L.pay 등 KG 이니시스 간편결제로 주문할 때, 결제가 실패했는데도 관리자에게 "신규 주문 접수" 알림이 발송되던 문제를 수정했습니다.
- 간편결제로 결제하다 실패한 뒤 다시 결제하려 하면 "임시주문을 찾을 수 없습니다" 오류가 뜨며 재결제가 불가능하던 문제를 수정했습니다. 이제 결제 실패 후에도 주문서가 유지됩니다.
- 간편결제 주문을 취소할 때 카드 취소가 함께 이뤄지지 않을 수 있던 문제를 수정했습니다.
### Changed
- 간편결제가 정식 결제수단으로 취급되어 주문 내역에 실제로 선택한 결제수단(예: 네이버페이)이 그대로 기록됩니다.
- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 고정 · KG이니시스"로 표시됩니다. 간편결제는 KG 이니시스 전용이므로 다른 결제대행사로 바꿀 수 없습니다.
- 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화되어 주문서 입력값이 초기화되는 등의 부작용이 사라집니다.
## [1.0.0] - 2026-07-01
### Added
@@ -1,7 +1,7 @@
{
"name": "plugins/sirsoft-pay_kginicis",
"description": "KG Inicis PG Plugin for G7 platform",
"version": "1.0.0",
"version": "1.0.1",
"type": "library",
"authors": [
{
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-pay_kginicis",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-pay_kginicis",
"version": "1.0.0",
"version": "1.0.1",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-pay_kginicis",
"version": "1.0.0",
"version": "1.0.1",
"type": "module",
"private": true,
"scripts": {
@@ -5,7 +5,7 @@
"ko": "KG 이니시스",
"en": "KG Inicis"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis/blob/main/CHANGELOG.md",
@@ -1,127 +1,108 @@
/**
* 주문 응답 인터셉터 회귀 테스트
* 주문 생성 인터셉터 테스트 — 이슈 #475
*
* KG 결제창을 띄운 뒤 체크아웃 템플릿의 fallback navigate 가 현재 경로를 다시
* 렌더링하면 배송지/주문자 입력 상태가 초기화된다. 인터셉터가 그 자기 경로
* navigate 1회만 차단하는지 검증한다.
* 확장 결제수단이 1급 시민이 되면서 인터셉터의 우회가 전부 제거됐다:
* - payment_method 가 'kginicis_*' 이면 'card' 로 위장 (서버 검증이 확장 ID 를 422 로 막음)
* - 응답의 requires_pg_payment / redirect_url 변조 (navigate-to-self 강제)
* - pg_payment_data 재구성
* - navigate-to-self 로 인한 체크아웃 재렌더(입력값 소실)를 막는 navigate suppressor
*
* 위장이 서버로 하여금 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인하게 만들어
* (a) 결제 실패 시 관리자 알림 오발송 (b) 임시주문 삭제 → 재결제 불가 를 일으켰다.
*
* 이제 결제창 진입은 서버 응답의 pg_payment_handler 를 템플릿이 dispatch 하는 경로로
* 처리된다. 템플릿의 PG 분기는 navigate 를 하지 않으므로 suppressor 도 불필요하다.
* 원본 결제수단(예: 'kginicis_lpay')은 서버가 pg_payment_data.payment_method 로 내려준다.
*
* 본 테스트는 인터셉터가 fetch / 라우터를 일절 건드리지 않음(no-op)을 회귀 방지로 고정한다.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { requestPaymentHandler } from '../handlers/requestPayment';
import { installOrderResponseInterceptor } from '../orderResponseInterceptor';
vi.mock('../handlers/requestPayment', () => ({
requestPaymentHandler: vi.fn().mockResolvedValue(undefined),
}));
interface MutatedOrderResponse {
data?: {
redirect_url?: string;
requires_pg_payment?: boolean;
};
}
const ORDER_CREATE_URL = '/api/modules/sirsoft-ecommerce/user/orders';
function windowRecord(): Record<string, unknown> {
return window as unknown as Record<string, unknown>;
}
function installTemplateRouter(navigate: ReturnType<typeof vi.fn>): { navigate: (path: string, options?: unknown) => unknown } {
const router = { navigate };
windowRecord()['__templateApp'] = { getRouter: () => router };
return router;
}
function mockOrderCreateFetch(): ReturnType<typeof vi.fn> {
const responseBody = {
success: true,
data: {
order: { order_number: 'ORD-001' },
redirect_url: '/shop/orders/ORD-001/complete',
requires_pg_payment: true,
pg_provider: 'sirsoft-kginicis',
pg_payment_data: {
order_number: 'ORD-001',
order_name: '테스트 주문',
amount: 10000,
},
},
};
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify(responseBody), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
window.fetch = fetchSpy as unknown as typeof fetch;
return fetchSpy;
}
async function createOrder(): Promise<MutatedOrderResponse> {
const response = await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
method: 'POST',
body: JSON.stringify({ payment_method: 'card' }),
});
return (await response.json()) as MutatedOrderResponse;
}
describe('installOrderResponseInterceptor', () => {
beforeEach(() => {
window.history.pushState({}, '', '/shop/checkout');
vi.mocked(requestPaymentHandler).mockClear();
window.history.replaceState({}, '', '/shop/checkout');
vi.spyOn(console, 'info').mockImplementation(() => {});
});
afterEach(() => {
const w = windowRecord();
const suppressor = w['__sirsoftKginicisNavigateSuppressor'] as { restore?: () => void } | undefined;
suppressor?.restore?.();
delete w['__sirsoftKginicisInterceptorInstalled'];
delete w['__sirsoftPgOriginalFetch'];
delete w['__sirsoftKginicisNavigateSuppressor'];
delete w['__templateApp'];
document.body.innerHTML = '';
vi.restoreAllMocks();
});
it('KG 주문 생성 응답 후 체크아웃 자기 경로 navigate 1회를 차단한다', async () => {
const originalNavigate = vi.fn();
const router = installTemplateRouter(originalNavigate);
mockOrderCreateFetch();
it('window.fetch 를 래핑하지 않는다 (no-op)', () => {
const originalFetch = vi.fn();
window.fetch = originalFetch as unknown as typeof fetch;
installOrderResponseInterceptor();
const body = await createOrder();
expect(requestPaymentHandler).toHaveBeenCalledWith({
params: {
pgPaymentData: expect.objectContaining({ order_number: 'ORD-001' }),
paymentMethod: 'card',
},
});
expect(body.data?.requires_pg_payment).toBe(false);
expect(body.data?.redirect_url).toBe('/shop/checkout');
router.navigate(body.data?.redirect_url ?? '');
expect(originalNavigate).not.toHaveBeenCalled();
router.navigate('/shop/orders/ORD-001/complete');
expect(originalNavigate).toHaveBeenCalledWith('/shop/orders/ORD-001/complete');
expect(window.fetch).toBe(originalFetch);
});
it('현재 체크아웃 경로가 아닌 navigate 는 차단하지 않는다', async () => {
const originalNavigate = vi.fn();
const router = installTemplateRouter(originalNavigate);
mockOrderCreateFetch();
it('간편결제 payment_method 를 card 로 위장하지 않고 원본 그대로 전송한다', async () => {
let sentBody = '';
window.fetch = vi.fn().mockImplementation(async (_input: unknown, init?: RequestInit) => {
sentBody = String(init?.body ?? '');
return new Response('{}', { status: 200 });
}) as unknown as typeof fetch;
installOrderResponseInterceptor();
await createOrder();
await window.fetch(ORDER_CREATE_URL, {
method: 'POST',
body: JSON.stringify({ payment_method: 'kginicis_lpay' }),
});
router.navigate('/shop/cart', { replace: true });
// 위장이 부활하면 서버가 PG 결제 주문으로 인식하지 못해 #475 가 재발한다.
expect(JSON.parse(sentBody).payment_method).toBe('kginicis_lpay');
});
expect(originalNavigate).toHaveBeenCalledWith('/shop/cart', { replace: true });
it('주문 생성 응답을 변조하지 않는다', async () => {
const serverBody = {
success: true,
data: {
order: { order_number: 'ORD-1' },
redirect_url: '/shop/orders/ORD-1/complete',
requires_pg_payment: true,
pg_provider: 'sirsoft-kginicis',
pg_payment_handler: 'sirsoft-pay_kginicis.requestPayment',
},
};
window.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify(serverBody), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
) as unknown as typeof fetch;
installOrderResponseInterceptor();
const response = await window.fetch(ORDER_CREATE_URL, { method: 'POST', body: '{}' });
const body = await response.json();
expect(body.data.requires_pg_payment).toBe(true);
expect(body.data.redirect_url).toBe('/shop/orders/ORD-1/complete');
expect(body.data.pg_payment_handler).toBe('sirsoft-pay_kginicis.requestPayment');
});
it('라우터의 navigate 를 가로채지 않는다 (suppressor 제거)', () => {
const navigate = vi.fn();
windowRecord()['__templateApp'] = {
getRouter: () => ({ navigate }),
};
installOrderResponseInterceptor();
const router = (windowRecord()['__templateApp'] as { getRouter: () => { navigate: unknown } }).getRouter();
// suppressor 가 부활하면 navigate 가 패치된 함수로 교체된다.
expect(router.navigate).toBe(navigate);
});
});
@@ -16,6 +16,12 @@ interface PgPaymentData {
order_name: string;
amount: number;
currency?: string;
/**
* 서버가 저장한 결제수단 ID — 확장 수단이면 확장 ID 그대로(예: 'kginicis_lpay').
* 이 값이 gopaymethod / CBT paymethod 매핑의 입력이 된다. 확장 결제수단이 1급 시민이
* 되면서 서버가 원본 수단을 알고 내려주므로, 프론트 인터셉터가 따로 전달할 필요가 없다(#475).
*/
payment_method?: string;
customer_name?: string;
customer_email?: string;
customer_phone?: string;
@@ -616,7 +622,11 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis
}
const localState = window.__templateApp?.globalState?._local;
const paymentMethod = paramPaymentMethod ?? localState?.paymentMethod ?? 'card';
// 서버가 저장한 결제수단(payment_method)이 SSoT — 확장 수단이면 확장 ID 가 그대로 온다(#475).
const paymentMethod = paramPaymentMethod
?? pgPaymentData.payment_method
?? localState?.paymentMethod
?? 'card';
const G7Core = (window as any).G7Core;
@@ -1,348 +1,46 @@
/**
* 주문 생성 API 응답 인터셉터
* 주문 생성 응답 인터셉터 — 제거됨 (no-op)
*
* 체크아웃 템플릿(_checkout_summary.json)에는 'sirsoft-tosspayments' 분기만
* 정의되어 있어서, 'sirsoft-kginicis' PG는 navigate 기본 분기로 떨어져
* /shop/orders/{order_number}/complete 로 이동해버림 (결제창 미노출).
* 과거에는 이 인터셉터가 다음 우회를 수행했다:
*
* 코어/템플릿 수정 없이 이 문제를 우회하기 위해 plugin loading 시점에
* window.fetch 를 래핑해 다음을 수행:
* 1. 요청 body 의 `payment_method` 가 'kginicis_*' 이면 'card' 로 위장
* (서버 검증이 확장 ID 를 422 로 막았기 때문)
* 2. 응답의 `requires_pg_payment` 를 false 로, `redirect_url` 을 self 로 변조해
* 템플릿 navigate 를 무력화하고 결제창을 직접 띄움
* 3. `pg_payment_data` 를 order 응답에서 직접 재구성
* 4. navigate-to-self 로 인한 체크아웃 재렌더(입력값 소실)를 막기 위해
* 라우터의 navigate 를 1회 가로채는 suppressor 설치
*
* 1. POST /api/modules/sirsoft-ecommerce/user/orders 응답을 가로챈다
* 2. data.pg_provider === 'sirsoft-kginicis' 이면 requestPayment 핸들러를 직접 호출하여 결제창 띄움
* 3. data.redirect_url 을 현재 URL로 교체하고 requires_pg_payment를 false로 변경
* 4. 템플릿 fallback 분기의 navigate-to-self 1회를 차단해 체크아웃 입력 상태 보존
* 이 우회는 두 개의 심각한 결함을 낳았다 — 서버가 간편결제 주문을 "PG 결제가 아닌 주문"
* 으로 오인해 (a) 결제 실패했는데 관리자에게 신규주문 알림이 발송되고 (b) 임시주문이
* 즉시 삭제되어 재결제가 불가능해졌다.
*
* 결과: 체크아웃 페이지에 머문 채 PG 팝업이 뜨고, PG 콜백이 정식 complete 페이지로 redirect.
* 이제 코어가 확장 결제수단을 1급 시민으로 처리한다:
* - 서버 검증이 결제수단 카탈로그를 화이트리스트로 사용 (확장 ID 통과)
* - 플러그인이 `pg_provider` 를 자기 PG 로 고정 선언 → PG 결제 주문으로 정상 판정
* - provider 가 선언한 `payment_handler` 가 응답의 `pg_payment_handler` 로 내려가고,
* 템플릿이 그 핸들러를 직접 dispatch 한다 (navigate 분기 자체가 실행되지 않음)
* - 원본 결제수단(예: 'kginicis_lpay')은 서버가 `pg_payment_data.payment_method` 로
* 내려주므로 gopaymethod / CBT paymethod 매핑이 그대로 동작한다
*
* 템플릿이 PG 분기에서 navigate 를 하지 않으므로 navigate suppressor 도 불필요하다.
*
* 파일과 export 는 호출부(index.ts) 호환을 위해 유지하되 동작은 no-op 이다.
*
* @see https://github.com/gnuboard/dev-g7/issues/475
*/
import { requestPaymentHandler } from './handlers/requestPayment';
const ORDER_CREATE_PATH = '/api/modules/sirsoft-ecommerce/user/orders';
const TARGET_PG_PROVIDER = 'sirsoft-kginicis';
const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis';
const NAVIGATE_SUPPRESSOR_KEY = '__sirsoftKginicisNavigateSuppressor';
const logger = {
info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),
warn: (...args: unknown[]) => console.warn(`[${PLUGIN_IDENTIFIER}]`, ...args),
error: (...args: unknown[]) => console.error(`[${PLUGIN_IDENTIFIER}]`, ...args),
};
interface OrderCreateResponseBody {
success?: boolean;
message?: string;
data?: {
order?: { order_number?: string };
redirect_url?: string;
requires_pg_payment?: boolean;
pg_provider?: string;
pg_payment_data?: Record<string, unknown>;
};
}
interface RouterLike {
navigate: (path: string, options?: unknown) => unknown;
}
interface NavigateSuppressorState {
restore: () => void;
timer: number;
}
function extractPaymentMethodFromBody(body: string): string | undefined {
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
return parsed['payment_method'] as string | undefined;
} catch {
return undefined;
}
}
/**
* KG 이니시스 전용 결제수단 선택 시 _local.paymentMethod 가
* 'kginicis_lpay', 'kginicis_japan_paypay' 등의 PG 식별자로 설정된다. 그러나 코어 PaymentMethodEnum 은
* card/vbank/bank/phone 등 일반 결제수단만 허용하므로 백엔드 validation 에서
* 'kginicis_*' 가 거부된다.
* 주문 생성 인터셉터 — 더 이상 fetch 를 래핑하지 않는다.
*
* 본 헬퍼는 요청 body 의 payment_method 가 'kginicis_*' 형태이면 'card' 로
* 치환한 새 body 를 반환한다 — UI/local state 는 PG 식별자를 그대로 유지해
* requestPayment 핸들러의 gopaymethod/CBT paymethod 매핑이 정상 동작한다.
*
* gnuboard5 shop/inicis/lpay_form.1.php 의 P_INI_PAYMENT/gopaymethod 패턴과
* 동일한 의미: '결제수단 = 신용카드, 간편결제 변종 = gopaymethod=LPAY'.
* 결제창 진입은 코어 응답의 `pg_payment_handler` 를 템플릿이 dispatch 하는 경로로 처리된다.
*/
function rewriteEasyPayInBody(body: string): { rewritten: string; originalMethod: string | undefined } {
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
const pm = parsed['payment_method'];
if (typeof pm === 'string' && pm.startsWith('kginicis_')) {
const original = pm;
parsed['payment_method'] = 'card';
return { rewritten: JSON.stringify(parsed), originalMethod: original };
}
return { rewritten: body, originalMethod: typeof pm === 'string' ? pm : undefined };
} catch {
return { rewritten: body, originalMethod: undefined };
}
}
function extractUrl(input: RequestInfo | URL): string {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.toString();
if (typeof Request !== 'undefined' && input instanceof Request) return input.url;
return String(input);
}
function extractMethod(input: RequestInfo | URL, init?: RequestInit): string {
if (init?.method) return init.method.toUpperCase();
if (typeof Request !== 'undefined' && input instanceof Request) return input.method.toUpperCase();
return 'GET';
}
function isTargetEndpoint(url: string, method: string): boolean {
if (method !== 'POST') return false;
// 쿼리스트링/해시 제거 후 경로만 비교
const path = url.split('?')[0].split('#')[0];
return path === ORDER_CREATE_PATH || path.endsWith(ORDER_CREATE_PATH);
}
function buildNoOpRedirectUrl(): string {
return window.location.pathname + window.location.search + window.location.hash;
}
function normalizePath(path: string): string {
try {
const url = new URL(path, window.location.origin);
return url.pathname + url.search + url.hash;
} catch {
return path;
}
}
/**
* 체크아웃 템플릿은 KG 결제창을 띄운 뒤에도 fallback navigate 를 실행한다.
* 현재 라우터는 같은 경로로 이동해도 route 를 다시 렌더링하므로 shipping/orderer
* local state 가 초기화될 수 있다. 코어/템플릿을 건드리지 않고 이 플러그인에서
* 현재 경로로 향하는 다음 navigate 1회만 소모한다.
*/
function suppressNextSamePathNavigate(targetPath: string): void {
const w = window as unknown as Record<string, unknown>;
const templateApp = w['__templateApp'] as { getRouter?: () => unknown } | undefined;
const router = templateApp?.getRouter?.() as RouterLike | undefined;
if (!router || typeof router.navigate !== 'function') {
return;
}
const existing = w[NAVIGATE_SUPPRESSOR_KEY] as NavigateSuppressorState | undefined;
existing?.restore();
const originalNavigate = router.navigate.bind(router);
const normalizedTarget = normalizePath(targetPath);
let restored = false;
let consumed = false;
let timer = 0;
const restore = (): void => {
if (restored) {
return;
}
restored = true;
if (router.navigate === patchedNavigate) {
router.navigate = originalNavigate;
}
window.clearTimeout(timer);
const current = w[NAVIGATE_SUPPRESSOR_KEY] as NavigateSuppressorState | undefined;
if (current?.restore === restore) {
delete w[NAVIGATE_SUPPRESSOR_KEY];
}
};
const patchedNavigate: RouterLike['navigate'] = (path, options) => {
const normalizedPath = normalizePath(path);
if (!consumed && normalizedPath === normalizedTarget) {
consumed = true;
logger.info('suppressed checkout self-navigation after PG popup', { path: normalizedPath });
restore();
return;
}
restore();
return originalNavigate(path, options);
};
timer = window.setTimeout(restore, 3000);
router.navigate = patchedNavigate;
w[NAVIGATE_SUPPRESSOR_KEY] = { restore, timer };
}
/**
* 응답 본문을 mutate한 새 Response 객체 생성
*
* 원본 Response 의 status/headers 는 보존하고 본문만 재구성.
*/
function mutateResponse(originalResponse: Response, mutatedBody: OrderCreateResponseBody): Response {
const json = JSON.stringify(mutatedBody);
return new Response(json, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: originalResponse.headers,
});
}
export function installOrderResponseInterceptor(): void {
if (typeof window === 'undefined' || typeof window.fetch !== 'function') {
return;
}
// 중복 설치 방지 — HMR / 다중 IIFE 로드 시
const flag = '__sirsoftKginicisInterceptorInstalled' as const;
const w = window as unknown as Record<string, unknown>;
if (w[flag]) {
return;
}
w[flag] = true;
// 최초 설치 플러그인이 원본 브라우저 fetch를 보존 (다른 PG 인터셉터가 쌓이기 전)
const ORIGINAL_FETCH_KEY = '__sirsoftPgOriginalFetch';
if (!w[ORIGINAL_FETCH_KEY]) {
w[ORIGINAL_FETCH_KEY] = window.fetch.bind(window);
}
const originalFetch = window.fetch.bind(window);
window.fetch = async function patchedFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const url = extractUrl(input);
const method = extractMethod(input, init);
if (!isTargetEndpoint(url, method)) {
return originalFetch(input, init);
}
// 요청 body에서 payment_method 추출 (vbank, bank, phone 등 비카드 결제수단 감지)
// 동시에 'kginicis_*' 간편결제 식별자는 'card' 로 치환하여 backend enum 검증을 통과시킨다.
let paymentMethod: string | undefined;
let mutatedInit: RequestInit | undefined = init;
if (init?.body && typeof init.body === 'string') {
const { rewritten, originalMethod } = rewriteEasyPayInBody(init.body);
paymentMethod = originalMethod;
if (rewritten !== init.body) {
logger.info('rewrote request payment_method', { from: originalMethod, to: 'card' });
mutatedInit = { ...init, body: rewritten };
}
}
// G7Core 로컬 상태 fallback
if (!paymentMethod) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
paymentMethod = ((window as any).G7Core)?.state?.getLocal?.()?.paymentMethod as string | undefined;
} catch { /* ignore */ }
}
const response = await originalFetch(input, mutatedInit);
// 본문은 한 번만 읽을 수 있으므로 클론
let cloned: Response;
try {
cloned = response.clone();
} catch {
return response;
}
let body: OrderCreateResponseBody | null = null;
try {
body = (await cloned.json()) as OrderCreateResponseBody;
} catch {
// 비-JSON 응답이면 그대로 통과
return response;
}
const data = body?.data;
if (!data) return response;
// 'kginicis_*' 결제수단 선택 시 기본 PG 가 다른 PG
// 라도 KG 이니시스 결제창을 강제로 열어야 한다. 이때 backend 가 requires_pg_payment
// 를 false 로 응답하거나 pg_provider 를 다른 PG 로 응답해도 KG 흐름으로 강제.
const isKginicisEasyPay = typeof paymentMethod === 'string' && paymentMethod.startsWith('kginicis_');
const requiresPg = data.requires_pg_payment === true;
const isKginicis = data.pg_provider === TARGET_PG_PROVIDER;
// 일반 결제: requires_pg_payment 가 true + pg_provider 가 KG 이니시스 → 처리
// 간편결제(kginicis_*): pg_provider / requires_pg_payment 무관 → 강제 처리
if (!isKginicisEasyPay && (!requiresPg || !isKginicis)) {
return response;
}
// pg_payment_data: backend 응답에 포함되거나, 간편결제 + 기본 PG 미설정/다른 PG 시
// order 데이터에서 직접 구성 (NicePay 의 동일 패턴 차용)
let pgPaymentData = data.pg_payment_data as Record<string, unknown> | undefined;
if (!pgPaymentData && isKginicisEasyPay) {
const orderData = (data as unknown as { order?: Record<string, unknown> }).order;
if (orderData) {
const options = orderData.options as Array<Record<string, unknown>> | undefined;
const firstName = (options?.[0]?.product_name as string | undefined) ?? String(orderData.order_number ?? '');
const orderName = (options?.length ?? 0) > 1
? `${firstName} 외 ${(options?.length ?? 0) - 1}건`
: firstName;
pgPaymentData = {
order_number: orderData.order_number,
order_name: orderName,
amount: Math.floor(Number(orderData.total_amount ?? 0)),
currency: String(
orderData.currency
?? orderData.currency_code
?? (paymentMethod?.startsWith('kginicis_japan_') ? 'JPY' : 'KRW')
),
customer_name: orderData.orderer_name ?? null,
customer_email: orderData.orderer_email ?? null,
customer_phone: String(orderData.orderer_phone ?? '').replace(/[^0-9]/g, ''),
};
logger.info('pg_payment_data constructed from order (기본 PG 미설정 또는 다른 PG)', {
order_number: pgPaymentData.order_number,
amount: pgPaymentData.amount,
});
}
}
if (!pgPaymentData) {
logger.warn('kginicis order detected but pg_payment_data missing');
return response;
}
logger.info('intercepted order create response — opening PG popup');
// 1) 결제창 호출 (비동기 — 팝업이 뜨도록 fire-and-forget)
// 실패 시 requestPaymentHandler 내부에서 isSubmittingOrder=false 처리됨
void requestPaymentHandler({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params: { pgPaymentData: pgPaymentData as any, paymentMethod },
});
// 2) 응답 mutate — 템플릿의 navigate fallback 을 무력화
// - requires_pg_payment: false (혹시 다른 곳에서 참조해도 안전)
// - redirect_url: 현재 URL (다음 navigate-to-self 1회는 플러그인에서 차단)
const redirectUrl = buildNoOpRedirectUrl();
suppressNextSamePathNavigate(redirectUrl);
const mutatedBody: OrderCreateResponseBody = {
...body,
data: {
...data,
requires_pg_payment: false,
redirect_url: redirectUrl,
},
};
return mutateResponse(response, mutatedBody);
};
logger.info('order response interceptor installed');
logger.info('order interceptor is a no-op — payment entry is dispatched via pg_payment_handler');
}
@@ -7,7 +7,10 @@ namespace Plugins\Sirsoft\PayKginicis\Listeners;
use App\Contracts\Extension\HookListenerInterface;
/**
* 이커머스 결제수단 설정 화면에서 KG 이니시스 간편결제를 PG 선택 불필요 항목으로 표시한다.
* 이커머스 결제수단 설정 화면에 KG 이니시스 테스트모드 경고를 주입한다.
*
* 간편결제의 PG 표시(PG 고정 배지)는 코어 레이아웃이 결제수단 카탈로그의 pg_locked /
* needs_pg 를 읽어 직접 처리하므로 이 리스너가 관여하지 않는다(#475).
*/
class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterface
{
@@ -24,10 +27,6 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
private const TEST_MODE_CONDITION = 'kginicis_test_mode_status.data?.is_test_mode === true';
private const CORE_NO_PG_METHODS = "['point','deposit','free','dbank']";
private const KGINICIS_NO_PG_METHODS = "['point','deposit','free','dbank','kginicis_samsung_pay','kginicis_naverpay','kginicis_lpay','kginicis_kakaopay','kginicis_japan_paypay','kginicis_japan_cvs']";
/**
* 이 리스너가 구독하는 훅 정의를 반환합니다.
*
@@ -37,7 +36,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
{
return [
'core.layout_extension.after_apply' => [
'method' => 'markEasyPayMethodsAsPgNotRequired',
'method' => 'adjustPaymentMethodsLayout',
'type' => 'filter',
'priority' => 20,
],
@@ -52,50 +51,30 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
public function handle(...$args): void {}
/**
* 이커머스 결제수단 설정 레이아웃에 KG 이니시스 간편결제를 PG 선택 불필요 항목으로 반영합니다.
* 이커머스 결제수단 설정 레이아웃에 테스트모드 경고를 주입합니다.
*
* 과거에는 코어 레이아웃의 "PG 불필요 결제수단" 하드코딩 배열
* (`['point','deposit','free','dbank']`)을 문자열 치환해 간편결제를 그 목록에 끼워 넣어
* PG 선택 셀렉트를 숨겼다. 그러나 간편결제는 "PG 불필요" 가 아니라 "PG 고정" 이며,
* 이 표시 왜곡은 실제로 서버가 간편결제를 PG 없는 주문으로 오인하던 결함과 짝을 이뤘다(#475).
*
* 이제 결제수단 카탈로그가 `pg_locked` / `needs_pg` 를 내려주고 코어 레이아웃이 그 값으로
* 직접 3분기(PG 고정 배지 / PG 선택 / PG 불필요)하므로 문자열 치환이 불필요하다.
* (플러그인 간 누적 치환 충돌 위험도 함께 사라진다.)
*
* @param array<string, mixed> $layout 대상 레이아웃 정의
* @param int $templateId 레이아웃이 속한 템플릿 ID
* @return array<string, mixed> 보정된 레이아웃 정의
*/
public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array
public function adjustPaymentMethodsLayout(array $layout, int $templateId): array
{
if (($layout['layout_name'] ?? '') !== self::TARGET_LAYOUT) {
return $layout;
}
$layout = $this->replaceNoPgMethodExpressions($layout);
return $this->ensureTestModeWarning($layout);
}
/**
* 레이아웃 노드를 재귀 순회하며 코어의 PG 불필요 결제수단 목록을 이니시스 목록으로 치환합니다.
*
* @param array<string, mixed> $node 순회 대상 노드
* @return array<string, mixed> 치환이 반영된 노드
*/
private function replaceNoPgMethodExpressions(array $node): array
{
foreach ($node as $key => $value) {
if (is_array($value)) {
$node[$key] = $this->replaceNoPgMethodExpressions($value);
continue;
}
if (is_string($value) && str_contains($value, self::CORE_NO_PG_METHODS)) {
$node[$key] = str_replace(
self::CORE_NO_PG_METHODS,
self::KGINICIS_NO_PG_METHODS,
$value
);
}
}
return $node;
}
/**
* 테스트 모드 경고에 필요한 데이터 소스와 안내 노드를 레이아웃에 보장합니다.
*
@@ -25,6 +25,11 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
{
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis';
/**
* 이 플러그인이 제공하는 PG 식별자 (RegisterPgProviderListener 의 provider id 와 일치).
*/
private const PG_PROVIDER_ID = 'kginicis';
private const DOMESTIC_EASY_PAY_SETTINGS = [
'kginicis_samsung_pay' => 'easy_pay_samsung_pay',
'kginicis_naverpay' => 'easy_pay_naverpay',
@@ -59,7 +64,7 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
* 이커머스 결제수단 목록에 KG 이니시스 전용 결제수단 inject.
*
* @param array $methods builtin 결제수단 배열 (코어 EcommerceSettingsService::getBuiltinPaymentMethods)
* @return array KG 이니시스 entry 가 phone~point 사이에 삽입된 배열
* @return array KG 이니시스 entry 가 phone~point 사이에 삽입된 배열
*/
public function injectEasyPayMethods(array $methods): array
{
@@ -153,8 +158,6 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
/**
* 간편결제 버튼에 브랜드 중심 설명을 사용할지 반환합니다.
*
* @return bool
*/
private function usesBrandButton(): bool
{
@@ -163,9 +166,6 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
/**
* 기존 플러그인 설정의 국내 간편결제 활성값을 읽습니다.
*
* @param string $id
* @return bool
*/
private function domesticMethodEnabled(string $id): bool
{
@@ -178,7 +178,6 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
* KG 이니시스 플러그인 boolean 설정을 조회합니다.
*
* @param string $key 설정 키
* @return bool
*/
private function settingEnabled(string $key): bool
{
@@ -207,12 +206,17 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
'icon' => $icon,
'source' => 'plugin:sirsoft-pay_kginicis',
'defaults' => [
// PG 선택 불필요 — orderResponseInterceptor 가 prefix 'kginicis_' 를 인식해
// 기본 PG 사 설정과 무관하게 KG 이니시스 결제 흐름으로 강제.
'pg_provider' => null,
// 간편결제는 KG 이니시스 결제창을 통해서만 처리되므로 PG 를 자기 자신으로 고정한다.
// null 로 두면 코어가 PG 없는 결제수단으로 오인해 결제 실패 주문에 관리자 알림이
// 발송되고 임시주문이 삭제되어 재결제가 막힌다(#475).
'pg_provider' => self::PG_PROVIDER_ID,
'pg_locked' => true,
'needs_pg' => true,
'refund_method' => 'pg',
'is_active' => $isActive,
'min_order_amount' => 0,
'stock_deduction_timing' => 'payment_complete',
'mileage_deduction_timing' => 'payment_complete',
],
];
}
@@ -26,16 +26,11 @@ class RegisterPgProviderListener implements HookListenerInterface
'kginicis_kakaopay' => 'easy_pay_kakaopay',
];
/**
* getSubscribedHooks
*
* @return array
*/
/**
* 이 리스너가 구독하는 훅 정의를 반환합니다.
*
* @return array 훅 이름 => 구독 설정
*/
public static function getSubscribedHooks(): array
{
return [
@@ -73,25 +68,22 @@ class RegisterPgProviderListener implements HookListenerInterface
'name' => localized_label(nameKey: 'sirsoft-pay_kginicis::provider.name'),
'icon' => 'credit-card',
'supported_methods' => ['card', 'bank_transfer', 'virtual_account', 'mobile'],
// 코어의 provider-agnostic 결제 진입 dispatch — 주문 응답의 pg_payment_handler 로
// 내려가 템플릿이 그대로 호출한다. 미선언 시 프론트가 응답을 변조해 결제창을
// 직접 띄우는 우회가 필요해진다(#475).
'payment_handler' => 'sirsoft-pay_kginicis.requestPayment',
];
return $providers;
}
/**
* getClientConfig
*
* @param array $config
* @param string $provider
* @return array
*/
/**
* KG 이니시스 결제창 진입에 필요한 클라이언트 설정을 반환합니다.
*
* @param array $config 기존 클라이언트 설정
* @param string $provider 요청된 PG provider id
* @return array KG 이니시스 설정이 병합된 클라이언트 설정 (다른 PG 면 원본 그대로)
*/
public function getClientConfig(array $config, string $provider): array
{
if ($provider !== 'kginicis') {
@@ -111,32 +103,32 @@ class RegisterPgProviderListener implements HookListenerInterface
? 'https://stgstdpay.inicis.com/stdjs/INIStdPay.js'
: 'https://stdpay.inicis.com/stdjs/INIStdPay.js',
'callback_urls' => [
'signature' => '/plugins/sirsoft-pay_kginicis/payment/signature',
'close_report' => '/plugins/sirsoft-pay_kginicis/payment/close-report',
'callback' => '/plugins/sirsoft-pay_kginicis/payment/callback',
'close' => '/plugins/sirsoft-pay_kginicis/payment/close',
'cbt_checkout_token' => '/plugins/sirsoft-pay_kginicis/payment/cbt/checkout-token',
'cbt_hash_data' => '/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data',
'cbt_callback' => '/plugins/sirsoft-pay_kginicis/payment/cbt/callback',
'cbt_cvs_notify' => '/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify',
'cbt_auth_url' => $isTest ? self::CBT_AUTH_URL_TEST : self::CBT_AUTH_URL_LIVE,
'mobile_signature' => '/plugins/sirsoft-pay_kginicis/payment/mobile/signature',
'mobile_callback' => '/plugins/sirsoft-pay_kginicis/payment/mobile/callback',
'signature' => '/plugins/sirsoft-pay_kginicis/payment/signature',
'close_report' => '/plugins/sirsoft-pay_kginicis/payment/close-report',
'callback' => '/plugins/sirsoft-pay_kginicis/payment/callback',
'close' => '/plugins/sirsoft-pay_kginicis/payment/close',
'cbt_checkout_token' => '/plugins/sirsoft-pay_kginicis/payment/cbt/checkout-token',
'cbt_hash_data' => '/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data',
'cbt_callback' => '/plugins/sirsoft-pay_kginicis/payment/cbt/callback',
'cbt_cvs_notify' => '/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify',
'cbt_auth_url' => $isTest ? self::CBT_AUTH_URL_TEST : self::CBT_AUTH_URL_LIVE,
'mobile_signature' => '/plugins/sirsoft-pay_kginicis/payment/mobile/signature',
'mobile_callback' => '/plugins/sirsoft-pay_kginicis/payment/mobile/callback',
'mobile_vbank_notify' => '/plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify',
],
'japan_enabled' => $settings['japan_enabled'] ?? false,
'japan_enabled' => $settings['japan_enabled'] ?? false,
'japan_restrict_jpy_payment_methods' => (bool) ($settings['japan_restrict_jpy_payment_methods'] ?? false),
'japan_configured' => $this->isJapanConfigured($settings, $isTest),
'standard_configured' => $this->isStandardConfigured($settings, $isTest),
'mobile_configured' => $this->isMobileConfigured($settings, $isTest),
'use_escrow' => $settings['use_escrow'] ?? false,
'japan_mid' => $isTest
'japan_configured' => $this->isJapanConfigured($settings, $isTest),
'standard_configured' => $this->isStandardConfigured($settings, $isTest),
'mobile_configured' => $this->isMobileConfigured($settings, $isTest),
'use_escrow' => $settings['use_escrow'] ?? false,
'japan_mid' => $isTest
? KgInicisApiService::JAPAN_TEST_MID
: ($settings['live_japan_mid'] ?? ''),
'cbt_extra_data' => $this->buildCbtExtraData($settings),
'use_credit_point' => (bool) ($settings['use_credit_point'] ?? false),
'easy_pay_show_brand_button' => (bool) ($settings['easy_pay_show_brand_button'] ?? false),
'easy_pay_enabled_methods' => $this->enabledDomesticEasyPayMethods($settings),
'cbt_extra_data' => $this->buildCbtExtraData($settings),
'use_credit_point' => (bool) ($settings['use_credit_point'] ?? false),
'easy_pay_show_brand_button' => (bool) ($settings['easy_pay_show_brand_button'] ?? false),
'easy_pay_enabled_methods' => $this->enabledDomesticEasyPayMethods($settings),
]);
}
@@ -196,8 +188,6 @@ class RegisterPgProviderListener implements HookListenerInterface
* 현재 모드의 표준결제 MID/signKey 준비 여부를 반환합니다.
*
* @param array<string, mixed> $settings
* @param bool $isTest
* @return bool
*/
private function isStandardConfigured(array $settings, bool $isTest): bool
{
@@ -214,8 +204,6 @@ class RegisterPgProviderListener implements HookListenerInterface
* 현재 모드의 모바일결제 MID/hashKey 준비 여부를 반환합니다.
*
* @param array<string, mixed> $settings
* @param bool $isTest
* @return bool
*/
private function isMobileConfigured(array $settings, bool $isTest): bool
{
@@ -259,7 +247,7 @@ class RegisterPgProviderListener implements HookListenerInterface
return '';
}
return str_starts_with($suffix, self::LIVE_MID_PREFIX) ? $suffix : self::LIVE_MID_PREFIX . $suffix;
return str_starts_with($suffix, self::LIVE_MID_PREFIX) ? $suffix : self::LIVE_MID_PREFIX.$suffix;
}
private function getPluginSettings(): array
@@ -7,6 +7,17 @@ namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Listeners;
use PHPUnit\Framework\TestCase;
use Plugins\Sirsoft\PayKginicis\Listeners\AdjustEcommercePaymentMethodsLayoutListener;
/**
* 이커머스 결제수단 설정 레이아웃 보정 리스너 테스트 — 이슈 #475
*
* 과거에는 이 리스너가 코어 레이아웃의 "PG 불필요 결제수단" 하드코딩 배열을 문자열 치환해
* 간편결제를 그 목록에 끼워 넣었다(= 관리자 화면에 "PG 불필요" 로 표시). 그러나 간편결제는
* PG 가 불필요한 것이 아니라 특정 PG 에 고정된 수단이며, 이 표시 왜곡은 서버가 간편결제를
* PG 없는 주문으로 오인하던 결함과 짝을 이뤘다.
*
* 이제 결제수단 카탈로그가 pg_locked / needs_pg 를 내려주고 코어 레이아웃이 그 값으로 직접
* 분기하므로, 이 리스너의 책임은 테스트모드 경고 주입 하나만 남았다.
*/
class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
{
public function test_subscribes_to_layout_after_apply_as_filter(): void
@@ -18,14 +29,17 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
$hooks['core.layout_extension.after_apply']['type'] ?? null
);
$this->assertSame(
'markEasyPayMethodsAsPgNotRequired',
'adjustPaymentMethodsLayout',
$hooks['core.layout_extension.after_apply']['method'] ?? null
);
}
public function test_marks_kginicis_easy_pay_methods_as_pg_not_required_in_admin_ecommerce_settings(): void
public function test_does_not_string_substitute_pg_branch_expressions(): void
{
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
// 코어 레이아웃은 이제 pg_locked / needs_pg 로 직접 분기한다.
// 리스너가 표현식을 치환하면 코어의 분기 로직을 훼손하고,
// 플러그인이 여럿 설치되면 서로의 치환 결과 위에 누적 적용되어 충돌한다.
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
$layout = [
'layout_name' => 'admin_ecommerce_settings',
@@ -33,34 +47,26 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
[
'type' => 'basic',
'name' => 'Select',
'if' => "{{!['point','deposit','free','dbank'].includes(\$method.id) && (_local.form?.available_pg_providers ?? []).length > 0}}",
],
[
'type' => 'composite',
'name' => 'Toggle',
'props' => [
'disabled' => "{{_computed.isReadOnly || !['point','deposit','free','dbank'].includes(\$method.id) && !\$method.pg_provider && !_local.form?.order_settings?.default_pg_provider}}",
],
'if' => '{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length > 0}}',
],
],
];
$result = $listener->markEasyPayMethodsAsPgNotRequired($layout, 1);
$result = $listener->adjustPaymentMethodsLayout($layout, 1);
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
$this->assertIsString($json);
$this->assertStringContainsString('kginicis_samsung_pay', $json);
$this->assertStringContainsString('kginicis_naverpay', $json);
$this->assertStringContainsString('kginicis_lpay', $json);
$this->assertStringContainsString('kginicis_kakaopay', $json);
$this->assertStringContainsString('kginicis_japan_paypay', $json);
$this->assertStringContainsString('kginicis_japan_cvs', $json);
$this->assertStringNotContainsString("['point','deposit','free','dbank'].includes", $json);
// 코어 분기 표현식이 그대로 보존되어야 한다.
$this->assertStringContainsString('$method.pg_locked', $json);
$this->assertStringContainsString('$method.needs_pg', $json);
// 간편결제 ID 를 표현식에 끼워 넣지 않는다.
$this->assertStringNotContainsString('kginicis_naverpay', $json);
$this->assertStringNotContainsString('kginicis_lpay', $json);
}
public function test_adds_test_mode_warning_to_order_settings_tab(): void
{
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
$layout = [
'layout_name' => 'admin_ecommerce_settings',
@@ -75,8 +81,9 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
],
];
$result = $listener->markEasyPayMethodsAsPgNotRequired($layout, 1);
$result = $listener->markEasyPayMethodsAsPgNotRequired($result, 1);
// 멱등성: 두 번 적용해도 경고 노드가 중복되지 않아야 한다.
$result = $listener->adjustPaymentMethodsLayout($layout, 1);
$result = $listener->adjustPaymentMethodsLayout($result, 1);
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
$this->assertIsString($json);
@@ -99,17 +106,15 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
public function test_leaves_other_layouts_unchanged(): void
{
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
$layout = [
'layout_name' => 'shop/checkout',
'components' => [
[
'if' => "{{!['point','deposit','free','dbank'].includes(\$method.id)}}",
],
['if' => '{{$method.needs_pg}}'],
],
];
$this->assertSame($layout, $listener->markEasyPayMethodsAsPgNotRequired($layout, 1));
$this->assertSame($layout, $listener->adjustPaymentMethodsLayout($layout, 1));
}
}
@@ -20,7 +20,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
'japan_enabled' => true,
]);
$listener = new RegisterEasyPayMethodsListener();
$listener = new RegisterEasyPayMethodsListener;
$methods = $listener->injectEasyPayMethods([
['id' => 'card'],
@@ -43,7 +43,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
], array_column($methods, 'id'));
}
public function test_easy_pay_methods_do_not_require_pg_provider_in_saved_defaults(): void
public function test_easy_pay_methods_are_locked_to_own_pg_provider(): void
{
$this->mockSettings([
'easy_pay_samsung_pay' => false,
@@ -53,7 +53,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
'japan_enabled' => true,
]);
$listener = new RegisterEasyPayMethodsListener();
$listener = new RegisterEasyPayMethodsListener;
$methods = $listener->injectEasyPayMethods([
['id' => 'phone'],
@@ -67,10 +67,23 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
$this->assertCount(6, $easyPayMethods);
// 간편결제는 KG 이니시스 결제창을 통해서만 처리되므로 PG 를 자기 자신으로 고정 선언한다.
//
// 과거에는 pg_provider 를 null 로 두었고(= "PG 없는 결제수단"), 그 결과 서버가
// 간편결제 주문을 PG 결제가 아닌 주문으로 오인해 (a) 결제 실패했는데 관리자에게
// 신규주문 알림이 발송되고 (b) 임시주문이 즉시 삭제되어 재결제가 불가능해졌다(#475).
foreach ($easyPayMethods as $method) {
$this->assertArrayHasKey('defaults', $method);
$this->assertNull($method['defaults']['pg_provider'] ?? null);
// PG 고정 — null 이면 코어가 PG 없는 주문으로 오인한다.
$this->assertSame('kginicis', $method['defaults']['pg_provider'] ?? null);
$this->assertTrue($method['defaults']['pg_locked'] ?? false);
$this->assertTrue($method['defaults']['needs_pg'] ?? false);
$this->assertSame('pg', $method['defaults']['refund_method'] ?? null);
$this->assertFalse($method['defaults']['is_active'] ?? true);
$this->assertSame('payment_complete', $method['defaults']['stock_deduction_timing'] ?? null);
$this->assertSame('payment_complete', $method['defaults']['mileage_deduction_timing'] ?? null);
}
}
@@ -83,7 +96,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
// 테스트처럼 플러그인 설정이 아직 주입되지 않은 fallback 환경에서는
// 기존 긴 설명을 유지한다. 브랜드 버튼 설정이 켜진 경우는 아래 테스트에서 별도 검증.
$listener = new RegisterEasyPayMethodsListener();
$listener = new RegisterEasyPayMethodsListener;
$methods = $listener->injectEasyPayMethods([
['id' => 'phone'],
@@ -103,7 +116,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
'easy_pay_show_brand_button' => true,
]);
$listener = new RegisterEasyPayMethodsListener();
$listener = new RegisterEasyPayMethodsListener;
$methods = $listener->injectEasyPayMethods([
['id' => 'phone'],
@@ -128,7 +141,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
'japan_enabled' => false,
]);
$listener = new RegisterEasyPayMethodsListener();
$listener = new RegisterEasyPayMethodsListener;
$methods = $listener->injectEasyPayMethods([
['id' => 'phone'],
@@ -161,7 +174,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
'japan_enabled' => false,
]);
$listener = new RegisterEasyPayMethodsListener();
$listener = new RegisterEasyPayMethodsListener;
$methods = collect($listener->injectEasyPayMethods([
['id' => 'phone'],
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Upgrades;
use App\Extension\UpgradeContext;
use App\Upgrades\Data\Ext\Plugins\SirsoftPayKginicis\V1_0_1\Migrations\BackfillEasyPayPgProvider;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
require_once dirname(__DIR__, 3).'/upgrades/data/1.0.1/migrations/BackfillEasyPayPgProvider.php';
/**
* 저장된 주문설정의 KG 이니시스 간편결제 PG 고정 백필 테스트 — 이슈 #475
*
* 기존 설치 환경의 order_settings.json 에는 간편결제가 `pg_provider: null` 로 영속화되어
* 있다. 서버가 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인해 결제 실패
* 시 관리자 알림 오발송 + 임시주문 삭제(재결제 불가) 를 일으켰다.
*/
class BackfillEasyPayPgProviderTest extends TestCase
{
private string $settingsPath;
private bool $hadOriginalSettings = false;
private ?string $originalSettings = null;
protected function setUp(): void
{
parent::setUp();
$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();
}
/**
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
*
* @effects upgrade_step_backfills_settings_file, saved_null_pg_provider_self_healed
*/
public function test_backfills_pg_declaration_for_kginicis_easy_pay_methods(): void
{
// 결함 시절의 저장 상태 재현: pg_provider=null, 능력 키 부재
$this->writeSettings([
['id' => 'card', 'pg_provider' => 'kginicis'],
['id' => 'kginicis_naverpay', 'pg_provider' => null, 'is_active' => true],
['id' => 'kginicis_lpay', 'pg_provider' => null, 'is_active' => true],
]);
$this->runMigration();
$methods = $this->readMethods();
foreach (['kginicis_naverpay', 'kginicis_lpay'] as $id) {
$method = $methods[$id];
$this->assertSame('kginicis', $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' => 'kginicis'],
['id' => 'dbank', 'pg_provider' => ''],
['id' => 'nhnkcp_naverpay', 'pg_provider' => null],
['id' => 'kginicis_naverpay', 'pg_provider' => null],
]);
$this->runMigration();
$methods = $this->readMethods();
$this->assertSame('kginicis', $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['nhnkcp_naverpay']['pg_provider']);
$this->assertArrayNotHasKey('pg_locked', $methods['nhnkcp_naverpay']);
$this->assertSame('kginicis', $methods['kginicis_naverpay']['pg_provider']);
}
/**
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
*
* @effects upgrade_step_backfills_settings_file
*/
public function test_is_idempotent(): void
{
$this->writeSettings([
['id' => 'kginicis_naverpay', '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));
}
/**
* @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 BackfillEasyPayPgProvider)->run(new UpgradeContext(
fromVersion: '1.0.0',
toVersion: '1.0.1',
currentStep: '1.0.1',
));
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayKginicis\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* v1.0.1 업그레이드 스텝
*
* 저장된 이커머스 주문설정의 KG 이니시스 간편결제 항목에 PG 고정 선언을 백필한다.
*
* 모든 비즈니스 로직은 data/1.0.1/migrations/ 로 격리(AbstractUpgradeStep 규약).
*
* @upgrade-path B
*/
class Upgrade_1_0_1 extends AbstractUpgradeStep {}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayKginicis\V1_0_1\Migrations;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\File;
/**
* 저장된 이커머스 주문설정의 KG 이니시스 간편결제 항목에 PG 고정 선언을 백필한다.
*
* 배경 (이슈 #475):
*
* 간편결제 수단은 과거 `pg_provider: null` 로 등록되어 저장 파일에 그대로 영속화됐다.
* 서버는 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인했고, 그 결과
* (a) 결제가 실패했는데 관리자에게 신규주문 알림이 발송되고 (b) 임시주문이 즉시 삭제되어
* 재결제가 불가능해졌다.
*
* 현재 코드는 결제수단 카탈로그를 병합할 때 능력 선언(pg_provider / pg_locked / needs_pg /
* refund_method)을 플러그인 정의에서 가져오므로, 저장 파일에 남은 낡은 값이 있어도 런타임
* 동작은 이미 정상이다(자가 치유). 본 마이그레이션은 저장 파일 자체를 현재 선언과 일치시켜
* 설정 화면과 파일 내용이 어긋나 보이지 않게 한다.
*
* V-1 안전 격리 (docs/extension/upgrade-step-guide.md §13):
* - 파일 시스템 + FilePermissionHelper 만 사용 (이전 버전에도 존재하던 표면)
* - Service / Manager / Repository 컨테이너 해석 없음
*/
final class BackfillEasyPayPgProvider implements DataMigration
{
/**
* 이커머스 모듈의 주문설정 저장 경로.
*/
private const SETTINGS_PATH = 'app/modules/sirsoft-ecommerce/settings/order_settings.json';
/**
* 이 플러그인이 등록하는 간편결제 수단의 ID 접두사.
*/
private const METHOD_PREFIX = 'kginicis_';
/**
* 이 플러그인이 제공하는 PG 식별자.
*/
private const PG_PROVIDER_ID = 'kginicis';
/**
* 마이그레이션 식별자 (로그용).
*
* @return string 사람이 읽을 수 있는 짧은 식별자
*/
public function name(): string
{
return 'BackfillEasyPayPgProvider';
}
/**
* 저장된 주문설정의 간편결제 항목에 PG 고정 선언을 백필한다. idempotent.
*
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등)
*/
public function run(UpgradeContext $context): void
{
$path = storage_path(self::SETTINGS_PATH);
if (! File::exists($path)) {
$context->logger->info('[kginicis] 이커머스 주문설정 파일 없음 — 기본값으로 동작하므로 skip');
return;
}
$settings = json_decode(File::get($path), true);
if (! is_array($settings) || ! is_array($settings['payment_methods'] ?? null)) {
$context->logger->warning('[kginicis] 주문설정 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('[kginicis] 간편결제 PG 선언이 이미 최신 — 변경 없음');
return;
}
File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
FilePermissionHelper::inheritOwnershipFromParent($path);
$context->logger->info('[kginicis] 간편결제 PG 고정 선언 백필 완료', [
'pg_provider' => self::PG_PROVIDER_ID,
'updated_methods' => $updated,
]);
}
}
@@ -13,6 +13,15 @@
- PC 결제창 close-report와 승인 콜백이 경쟁할 때 결제 성공 주문이 실패 처리로 덮이지 않도록 주문번호 단위 콜백 락과 최신 결제 상태 가드를 적용했습니다.
- NHN KCP 부분취소 요청에서 `rem_mny`를 취소 후 잔액이 아닌 현재 부분취소 가능 금액으로 전달해 큰 금액 부분취소가 거절될 수 있던 문제를 수정했습니다.
- 이커머스 주문설정 화면에서 NHN KCP 플러그인이 테스트 모드일 때 운영 전환 경고와 설정 이동 버튼을 표시하도록 보강했습니다.
- PAYCO·네이버페이·카카오페이·애플페이 등 NHN KCP 간편결제로 주문할 때, 결제가 실패했는데도 관리자에게 "신규 주문 접수" 알림이 발송되던 문제를 수정했습니다.
- 간편결제로 결제하다 실패한 뒤 다시 결제하려 하면 "임시주문을 찾을 수 없습니다" 오류가 뜨며 재결제가 불가능하던 문제를 수정했습니다. 이제 결제 실패 후에도 주문서가 유지됩니다.
- 간편결제 주문을 취소할 때 카드 취소가 함께 이뤄지지 않을 수 있던 문제를 수정했습니다.
### Changed
- 간편결제가 정식 결제수단으로 취급되어 주문 내역에 실제로 선택한 결제수단(예: 네이버페이)이 그대로 기록됩니다.
- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 고정 · NHN KCP"로 표시됩니다. 간편결제는 NHN KCP 전용이므로 다른 결제대행사로 바꿀 수 없습니다.
- 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화됩니다.
## [1.0.0-beta.5] - 2026-06-23
File diff suppressed because one or more lines are too long
@@ -1,158 +1,125 @@
/**
* 주문 생성 인터셉터 테스트 — 이슈 #475
*
* 확장 결제수단이 1급 시민이 되면서 인터셉터의 우회(요청 위장 · 응답 변조 · pgPaymentData
* 재구성)가 모두 제거됐다. 남은 책임은 서버가 알 수 없는 클라이언트 디바이스 조건 하나뿐:
* Apple Pay 는 iOS 모바일에서만 동작한다.
*
* 회귀 방지 대상:
* - payment_method 를 'card' 로 위장하면 서버가 간편결제 주문을 "PG 결제가 아닌 주문"
* 으로 오인해 관리자 알림 오발송 + 임시주문 삭제(재결제 불가)가 재발한다.
* - 응답의 requires_pg_payment / redirect_url 을 변조하면 템플릿의
* pg_payment_handler dispatch 분기가 발화하지 않는다.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { requestPaymentHandler } from '../handlers/requestPayment';
import { installOrderResponseInterceptor } from '../orderResponseInterceptor';
vi.mock('../handlers/requestPayment', () => ({
requestPaymentHandler: vi.fn().mockResolvedValue(undefined),
}));
interface MutatedOrderResponse {
data?: {
redirect_url?: string;
requires_pg_payment?: boolean;
};
}
const ORDER_CREATE_URL = '/api/modules/sirsoft-ecommerce/user/orders';
function windowRecord(): Record<string, unknown> {
return window as unknown as Record<string, unknown>;
}
/** 서버 주문 생성 응답(정상 PG 결제 주문) */
function orderCreateResponse(): Response {
return new Response(JSON.stringify({
success: true,
data: {
order: { order_number: 'ORD-1' },
redirect_url: '/shop/orders/ORD-1/complete',
requires_pg_payment: true,
pg_provider: 'sirsoft-nhnkcp',
pg_payment_handler: 'sirsoft-pay_nhnkcp.requestPayment',
pg_payment_data: { order_number: 'ORD-1', amount: 1000, payment_method: 'nhnkcp_naverpay' },
},
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
describe('installOrderResponseInterceptor', () => {
beforeEach(() => {
document.documentElement.lang = 'ko';
window.history.pushState({}, '', '/shop/checkout');
vi.mocked(requestPaymentHandler).mockClear();
vi.spyOn(console, 'info').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
const w = windowRecord();
delete w['__sirsoftNhnkcpInterceptorInstalled'];
delete w['__sirsoftPgOriginalFetch'];
delete w['G7Core'];
document.body.innerHTML = '';
vi.restoreAllMocks();
});
it('NHN KCP 간편결제는 주문 생성 요청을 card로 전송하고 원래 결제수단으로 KCP 결제창을 호출한다', async () => {
it('간편결제 payment_method 를 card 로 위장하지 않고 원본 그대로 전송한다', async () => {
let sentBody = '';
window.fetch = vi.fn().mockImplementation(async (_input, init?: RequestInit) => {
sentBody = String(init?.body ?? '');
return new Response(JSON.stringify({
success: true,
data: {
order: {
order_number: 'ORD-100',
total_amount: 15000,
total_due_amount: 14000,
orderer_name: 'Tester',
orderer_email: 'tester@example.test',
orderer_phone: '010-1234-5678',
options: [{ product_name: '테스트 상품' }],
},
redirect_url: '/shop/orders/ORD-100/complete',
requires_pg_payment: true,
pg_provider: 'sirsoft-kginicis',
},
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as unknown as typeof fetch;
return orderCreateResponse();
});
installOrderResponseInterceptor();
const response = await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
await window.fetch(ORDER_CREATE_URL, {
method: 'POST',
body: JSON.stringify({ payment_method: 'nhnkcp_naverpay' }),
});
const body = (await response.json()) as MutatedOrderResponse;
expect(JSON.parse(sentBody)).toMatchObject({ payment_method: 'card' });
expect(requestPaymentHandler).toHaveBeenCalledWith({
params: {
pgPaymentData: expect.objectContaining({
order_number: 'ORD-100',
order_name: '테스트 상품',
amount: 14000,
customer_phone: '01012345678',
}),
paymentMethod: 'nhnkcp_naverpay',
},
});
expect(body.data?.requires_pg_payment).toBe(false);
expect(body.data?.redirect_url).toBe('/shop/checkout');
// 위장이 부활하면 서버가 PG 결제 주문으로 인식하지 못해 #475 가 재발한다.
expect(JSON.parse(sentBody).payment_method).toBe('nhnkcp_naverpay');
expect(sentBody).not.toContain('"payment_method":"card"');
});
it('iOS 모바일 기기가 아닌 Apple Pay 주문 생성 요청은 서버 전송 전에 차단한다', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
window.fetch = fetchSpy as unknown as typeof fetch;
it('서버 응답을 변조하지 않는다 (requires_pg_payment / redirect_url 보존)', async () => {
window.fetch = vi.fn().mockResolvedValue(orderCreateResponse());
installOrderResponseInterceptor();
const response = await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
const response = await window.fetch(ORDER_CREATE_URL, {
method: 'POST',
body: JSON.stringify({ payment_method: 'nhnkcp_naverpay' }),
});
const body = await response.json();
// 응답을 변조하면 템플릿의 pg_payment_handler dispatch 분기가 발화하지 않는다.
expect(body.data.requires_pg_payment).toBe(true);
expect(body.data.redirect_url).toBe('/shop/orders/ORD-1/complete');
expect(body.data.pg_payment_handler).toBe('sirsoft-pay_nhnkcp.requestPayment');
});
it('iOS 모바일 기기가 아닌 Apple Pay 주문은 서버 전송 전에 차단한다', async () => {
const fetchSpy = vi.fn().mockResolvedValue(orderCreateResponse());
window.fetch = fetchSpy;
// 데스크톱 UA (iOS 아님)
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
);
installOrderResponseInterceptor();
const response = await window.fetch(ORDER_CREATE_URL, {
method: 'POST',
body: JSON.stringify({ payment_method: 'nhnkcp_applepay' }),
});
const body = await response.json() as {
success?: boolean;
error?: string;
errors?: { payment_method?: string[] };
};
expect(fetchSpy).not.toHaveBeenCalled();
expect(response.status).toBe(422);
expect(body.success).toBe(false);
expect(body.error).toBe('애플페이는 IOS 기기에 모바일결제만 가능합니다.');
expect(body.errors?.payment_method?.[0]).toBe('애플페이는 IOS 기기에 모바일결제만 가능합니다.');
expect(requestPaymentHandler).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
const body = await response.json();
expect(body.errors.payment_method).toBeDefined();
});
it('일반 NHN KCP 주문 응답도 결제창을 호출하고 fallback 이동을 막는다', async () => {
window.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({
success: true,
data: {
order: { order_number: 'ORD-101' },
redirect_url: '/shop/orders/ORD-101/complete',
requires_pg_payment: true,
pg_provider: 'sirsoft-nhnkcp',
pg_payment_data: {
order_number: 'ORD-101',
order_name: '일반 주문',
amount: 20000,
},
},
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
) as unknown as typeof fetch;
it('주문 생성 외의 요청은 그대로 통과시킨다', async () => {
const originalResponse = new Response('{}', { status: 200 });
const fetchSpy = vi.fn().mockResolvedValue(originalResponse);
window.fetch = fetchSpy;
installOrderResponseInterceptor();
const response = await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
method: 'POST',
body: JSON.stringify({ payment_method: 'card' }),
});
const body = (await response.json()) as MutatedOrderResponse;
const response = await window.fetch('/api/modules/sirsoft-ecommerce/checkout', { method: 'GET' });
expect(requestPaymentHandler).toHaveBeenCalledWith({
params: {
pgPaymentData: expect.objectContaining({
order_number: 'ORD-101',
pay_method: 'card',
}),
paymentMethod: undefined,
},
});
expect(body.data?.requires_pg_payment).toBe(false);
expect(body.data?.redirect_url).toBe('/shop/checkout');
expect(response).toBe(originalResponse);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
@@ -17,10 +17,15 @@ interface PgPaymentData {
amount: number;
currency?: string;
pay_method?: string;
/**
* 서버가 저장한 결제수단 ID — 확장 수단이면 확장 ID 그대로(예: 'nhnkcp_naverpay').
* 확장 결제수단이 1급 시민이 되면서 서버가 원본 수단을 알고 내려준다(#475).
*/
payment_method?: string;
customer_name?: string;
customer_email?: string;
customer_phone?: string;
/** 복합과세 분할용 — orderResponseInterceptor가 data.order 에서 주입 */
/** 복합과세 분할용 */
tax_amount?: number;
vat_amount?: number;
tax_free_amount?: number;
@@ -235,7 +240,12 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis
return;
}
const paymentMethod = paramPaymentMethod ?? pgPaymentData.pay_method ?? 'card';
// 서버가 저장한 결제수단(payment_method)이 SSoT — 확장 수단이면 확장 ID 가 그대로 온다.
// params.paymentMethod 는 인터셉터 시절의 잔재이나 직접 호출 경로 호환을 위해 우선 존중한다.
const paymentMethod = paramPaymentMethod
?? pgPaymentData.payment_method
?? pgPaymentData.pay_method
?? 'card';
const isEasyPay = typeof paymentMethod === 'string' && paymentMethod.startsWith('nhnkcp_');
const G7Core = (window as any).G7Core;
@@ -62,11 +62,11 @@ export function setPaymentMethodHandler(action: any): void {
return;
}
// 확장 결제수단은 1급 시민 — 선택한 수단 ID 를 그대로 서버에 보낸다.
// (과거에는 서버 검증이 확장 ID 를 422 로 막아 'card' 로 정규화한 serverPaymentMethod 를
// 함께 저장했으나, 소비처가 없는 죽은 코드였고 위장 자체가 결함의 원인이었다 — #475)
const isEasyPay = typeof paymentMethod === 'string' && paymentMethod.startsWith('nhnkcp_');
G7Core?.state?.setLocal?.({
paymentMethod,
serverPaymentMethod: isEasyPay ? 'card' : paymentMethod,
});
G7Core?.state?.setLocal?.({ paymentMethod });
if (isEasyPay) {
updateEasyPayButtonStyles(paymentMethod);
@@ -1,24 +1,29 @@
/**
* 주문 생성 API 응답 인터셉터
* 주문 생성 요청 인터셉터 (Apple Pay 디바이스 가드 전용)
*
* 체크아웃 템플릿(_checkout_summary.json)에는 'sirsoft-tosspayments' 분기만
* 정의되어 있어서, 'sirsoft-nhnkcp' PG는 navigate 기본 분기로 떨어져
* /shop/orders/{order_number}/complete 로 이동해버림 (결제창 미노출).
* 과거에는 이 인터셉터가 다음을 우회 목적으로 수행했다:
*
* 코어/템플릿 수정 없이 이 문제를 우회하기 위해 plugin loading 시점에
* window.fetch 를 래핑해 다음을 수행:
* 1. 요청 body 의 `payment_method` 를 'card' 로 위장 (서버 검증이 확장 ID 를 422 로 막았기 때문)
* 2. 응답의 `requires_pg_payment` 를 false 로, `redirect_url` 을 self 로 변조
* 3. `pg_payment_data` 를 직접 재구성해 결제창을 강제로 띄움
*
* 1. POST /api/modules/sirsoft-ecommerce/user/orders 요청 body에서
* nhnkcp_* 간편결제 수단을 'card'로 교체 후 서버 전송
* 2. 응답에서 data.pg_provider === 'sirsoft-nhnkcp' 이면
* requestPayment 핸들러를 직접 호출하여 결제창 띄움
* 3. data.redirect_url 을 현재 URL로 교체하고 requires_pg_payment를 false로 변경
* → 템플릿 fallback 분기의 navigate 가 navigate-to-self 가 되어 실질적 이동 없음
* 이 우회는 두 개의 심각한 결함을 낳았다 — 서버가 간편결제 주문을 "PG 결제가 아닌 주문"
* 으로 오인해 (a) 결제 실패했는데 관리자에게 신규주문 알림이 발송되고 (b) 임시주문이
* 즉시 삭제되어 재결제가 불가능해졌다.
*
* 결과: 체크아웃 페이지에 머문 채 PG 팝업이 뜨고, PG 콜백이 정식 complete 페이지로 redirect.
* 이제 코어가 확장 결제수단을 1급 시민으로 받는다:
* - 서버 검증이 결제수단 카탈로그를 화이트리스트로 사용 (확장 ID 통과)
* - PG 플러그인이 `pg_provider` 를 자기 PG 로 고정 선언 → PG 결제 주문으로 정상 판정
* - provider 가 선언한 `payment_handler` 가 응답의 `pg_payment_handler` 로 내려가
* 템플릿이 결제 진입 핸들러를 그대로 dispatch
*
* 따라서 위장·응답변조·pgPaymentData 재구성은 모두 불필요해졌고 제거했다.
* 남은 책임은 서버가 알 수 없는 **클라이언트 디바이스 조건** 하나뿐이다:
* Apple Pay 는 iOS 모바일에서만 동작하므로 그 외 환경에서는 요청 전에 차단한다.
*
* @see https://github.com/gnuboard/dev-g7/issues/475
*/
import { requestPaymentHandler } from './handlers/requestPayment';
import {
applePayUnsupportedMessage,
isIosMobileDevice,
@@ -26,9 +31,7 @@ import {
} from './support/applePayDevice';
const ORDER_CREATE_PATH = '/api/modules/sirsoft-ecommerce/user/orders';
const TARGET_PG_PROVIDER = 'sirsoft-nhnkcp';
const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
const EASY_PAY_PREFIX = 'nhnkcp_';
const logger = {
info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),
@@ -36,23 +39,6 @@ const logger = {
error: (...args: unknown[]) => console.error(`[${PLUGIN_IDENTIFIER}]`, ...args),
};
interface OrderCreateResponseBody {
success?: boolean;
message?: string;
data?: {
order?: {
order_number?: string;
total_tax_amount?: number;
total_vat_amount?: number;
total_tax_free_amount?: number;
};
redirect_url?: string;
requires_pg_payment?: boolean;
pg_provider?: string;
pg_payment_data?: Record<string, unknown>;
};
}
function extractUrl(input: RequestInfo | URL): string {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.toString();
@@ -72,19 +58,18 @@ function isTargetEndpoint(url: string, method: string): boolean {
return path === ORDER_CREATE_PATH || path.endsWith(ORDER_CREATE_PATH);
}
function buildNoOpRedirectUrl(): string {
return window.location.pathname + window.location.search + window.location.hash;
}
function mutateResponse(originalResponse: Response, mutatedBody: OrderCreateResponseBody): Response {
const json = JSON.stringify(mutatedBody);
return new Response(json, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: originalResponse.headers,
});
function extractPaymentMethodFromBody(body: string): string | undefined {
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
return parsed['payment_method'] as string | undefined;
} catch {
return undefined;
}
}
/**
* Apple Pay 미지원 디바이스 차단 응답 (서버 검증과 동일한 422 형식).
*/
function buildPaymentMethodBlockedResponse(message: string): Response {
return new Response(JSON.stringify({
success: false,
@@ -102,25 +87,6 @@ function buildPaymentMethodBlockedResponse(message: string): Response {
});
}
function extractPaymentMethodFromBody(body: string): string | undefined {
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
return parsed['payment_method'] as string | undefined;
} catch {
return undefined;
}
}
function replacePaymentMethodInBody(body: string, replacement: string): string {
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
parsed['payment_method'] = replacement;
return JSON.stringify(parsed);
} catch {
return body;
}
}
export function installOrderResponseInterceptor(): void {
if (typeof window === 'undefined' || typeof window.fetch !== 'function') {
return;
@@ -133,14 +99,6 @@ export function installOrderResponseInterceptor(): void {
}
w[flag] = true;
// 최초 설치 플러그인이 원본 브라우저 fetch를 보존 (다른 PG 인터셉터가 쌓이기 전)
const ORIGINAL_FETCH_KEY = '__sirsoftPgOriginalFetch';
if (!w[ORIGINAL_FETCH_KEY]) {
w[ORIGINAL_FETCH_KEY] = window.fetch.bind(window);
}
// easy pay 요청 시 다른 PG 인터셉터(KG 이니시스 등)를 완전히 우회하는 원본 fetch
const browserFetch = w[ORIGINAL_FETCH_KEY] as typeof fetch;
const originalFetch = window.fetch.bind(window);
window.fetch = async function patchedFetch(
@@ -150,146 +108,27 @@ export function installOrderResponseInterceptor(): void {
const url = extractUrl(input);
const method = extractMethod(input, init);
// 주문 생성 엔드포인트가 아니면 바로 통과
if (!isTargetEndpoint(url, method)) {
return originalFetch(input, init);
}
// 요청 body에서 payment_method 추출 (nhnkcp_* 간편결제 감지)
let originalPaymentMethod: string | undefined;
let modifiedInit = init;
// Apple Pay 디바이스 가드 — 서버는 요청자의 디바이스를 알 수 없으므로
// 클라이언트에서만 판정 가능한 유일한 조건이다.
let paymentMethod: string | undefined;
if (init?.body && typeof init.body === 'string') {
const pm = extractPaymentMethodFromBody(init.body);
if (typeof pm === 'string' && pm.startsWith(EASY_PAY_PREFIX)) {
originalPaymentMethod = pm;
// nhnkcp_* → 'card'로 교체해 서버 전송 (서버 ValidationEnum에 없는 값 방지)
modifiedInit = { ...init, body: replacePaymentMethodInBody(init.body, 'card') };
logger.info(`easy pay detected: replacing payment_method '${pm}' → 'card'`);
}
paymentMethod = extractPaymentMethodFromBody(init.body);
}
// G7Core 로컬 상태 fallback — 간편결제 버튼 클릭 시 serverPaymentMethod='card'로
// 서버에 전달되어 요청 body에서 감지 불가인 경우 대비
if (!originalPaymentMethod) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const localPm = ((window as any).G7Core)?.state?.getLocal?.()?.paymentMethod;
if (typeof localPm === 'string' && localPm.startsWith(EASY_PAY_PREFIX)) {
originalPaymentMethod = localPm;
logger.info(`easy pay detected from local state: '${localPm}'`);
}
} catch { /* ignore */ }
}
if (isNhnKcpApplePayMethod(originalPaymentMethod) && !isIosMobileDevice()) {
if (isNhnKcpApplePayMethod(paymentMethod) && !isIosMobileDevice()) {
const message = applePayUnsupportedMessage();
logger.warn(message);
return buildPaymentMethodBlockedResponse(message);
}
// easy pay일 때 browserFetch(원본)를 사용해 KG 이니시스 등 다른 PG 인터셉터 우회
// 기본 PG가 KG 이니시스여도 NHN KCP 결제창이 열리도록 함
const fetchFn = originalPaymentMethod ? browserFetch : originalFetch;
const response = await fetchFn(input, modifiedInit);
let cloned: Response;
try {
cloned = response.clone();
} catch {
return response;
}
let body: OrderCreateResponseBody | null = null;
try {
body = (await cloned.json()) as OrderCreateResponseBody;
} catch {
return response;
}
const data = body?.data;
if (!data) return response;
const requiresPg = data.requires_pg_payment === true;
const isNhnkcp = data.pg_provider === TARGET_PG_PROVIDER;
// 일반 결제: pg_provider가 nhnkcp이고 requires_pg_payment=true인 경우에만 처리
// 간편결제: pg_provider 무관하게 NHN KCP 처리 (기본 PG가 KG 이니시스여도 KCP 결제창 띄움)
if (!originalPaymentMethod && (!requiresPg || !isNhnkcp)) {
return response;
}
if (originalPaymentMethod && !data.order && !data.pg_payment_data) {
return response;
}
// pg_payment_data: 서버 응답에 포함되거나,
// 간편결제 + 기본 PG가 타 PG인 경우 data.order에서 직접 구성
let pgPaymentData = data.pg_payment_data;
if (!pgPaymentData && originalPaymentMethod && data.order) {
const orderData = data.order as Record<string, unknown>;
const options = orderData.options as Array<Record<string, unknown>> | undefined;
const firstName = (options?.[0]?.product_name as string | undefined) ?? String(orderData.order_number ?? '');
const orderName = (options?.length ?? 0) > 1
? `${firstName} 외 ${(options?.length ?? 0) - 1}건`
: firstName;
pgPaymentData = {
order_number: orderData.order_number,
order_name: orderName,
amount: Math.floor(Number(orderData.total_due_amount ?? orderData.total_amount ?? 0)),
currency: 'KRW',
customer_name: orderData.orderer_name ?? null,
customer_email: orderData.orderer_email ?? null,
customer_phone: String(orderData.orderer_phone ?? '').replace(/[^0-9]/g, ''),
};
logger.info('pg_payment_data constructed from order (기본 PG가 타 PG)', {
order_number: pgPaymentData.order_number,
amount: pgPaymentData.amount,
});
}
if (!pgPaymentData) {
logger.warn('nhnkcp order detected but pg_payment_data missing');
return response;
}
logger.info('intercepted order create response — opening PG popup');
// 주문 세금 정보 추출 — PC 결제창의 복합과세 분할 필드 전달용
const orderTax = data.order
? {
tax_amount: data.order.total_tax_amount ?? 0,
vat_amount: data.order.total_vat_amount ?? 0,
tax_free_amount: data.order.total_tax_free_amount ?? 0,
}
: {};
// 간편결제가 아닌 경우 요청 body의 pay_method를 pgPaymentData에 주입
const enrichedPgPaymentData = (!originalPaymentMethod && modifiedInit?.body && typeof modifiedInit.body === 'string')
? (() => {
const pm = extractPaymentMethodFromBody(modifiedInit!.body as string);
return pm ? { ...pgPaymentData, pay_method: pm, ...orderTax } : { ...pgPaymentData, ...orderTax };
})()
: { ...pgPaymentData, ...orderTax };
void requestPaymentHandler({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params: {
pgPaymentData: enrichedPgPaymentData as any,
paymentMethod: originalPaymentMethod,
},
});
const mutatedBody: OrderCreateResponseBody = {
...body,
data: {
...data,
requires_pg_payment: false,
redirect_url: buildNoOpRedirectUrl(),
},
};
return mutateResponse(response, mutatedBody);
// 그 외에는 요청/응답을 일절 변조하지 않는다 — 코어가 확장 결제수단을
// 1급 시민으로 처리하고, 결제창 진입은 응답의 pg_payment_handler 로 dispatch 된다.
return originalFetch(input, init);
};
logger.info('order response interceptor installed');
logger.info('order request interceptor installed (Apple Pay device guard)');
}
@@ -17,13 +17,13 @@ use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayNhnkcp\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayNhnkcp\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayNhnkcp\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayNhnkcp\Concerns\RecordsPaymentWindowClosure;
use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay;
use Plugins\Sirsoft\PayNhnkcp\Concerns\SanitizesPgResponse;
use Plugins\Sirsoft\PayNhnkcp\Concerns\SerializesPaymentCallbacks;
use Plugins\Sirsoft\PayNhnkcp\Concerns\SendsKcpNotifyResponse;
use Plugins\Sirsoft\PayNhnkcp\Concerns\SerializesPaymentCallbacks;
use Plugins\Sirsoft\PayNhnkcp\Http\Requests\AuthCallbackRequest;
use Plugins\Sirsoft\PayNhnkcp\Http\Requests\VbankNotifyRequest;
use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
@@ -37,13 +37,13 @@ use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
*/
class PaymentCallbackController
{
use PreventsReplayCallback;
use IssuesReceiptCookie;
use PreventsReplayCallback;
use RecordsPaymentWindowClosure;
use ResolvesEasyPayDisplay;
use SanitizesPgResponse;
use SerializesPaymentCallbacks;
use SendsKcpNotifyResponse;
use SerializesPaymentCallbacks;
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
@@ -265,7 +265,7 @@ class PaymentCallbackController
// 가상계좌: 계좌 발급 완료 처리 (실제 입금은 vbankNotify에서 처리)
// KCP 콜백의 use_pay_method=VCNT 또는 주문의 payment_method=vbank 로 감지
$isVbank = ($validated['use_pay_method'] ?? '') === 'VCNT'
|| in_array($order->payment?->payment_method?->value, ['vbank', 'virtual_account'], true);
|| (bool) $order->payment?->isVirtualAccount();
if ($isVbank) {
return $this->handleVbankIssued($validated, $order, $encData, $encInfo, $ordrIdxx, $custIp, $request);
}
@@ -767,7 +767,7 @@ class PaymentCallbackController
return 'payment_not_found';
}
if (! in_array($payment->payment_method?->value, ['vbank', 'virtual_account'], true)) {
if (! $payment->isVirtualAccount()) {
return 'payment_method_not_vbank';
}
@@ -69,7 +69,7 @@ class UserVbankMockDepositController
return response()->json(['available' => false]);
}
$isVbank = in_array($payment->payment_method?->value, ['vbank', 'virtual_account'], true);
$isVbank = $payment->isVirtualAccount();
// 가상계좌 계좌번호가 발급됐고 아직 입금완료가 아닌 상태
// (handleVbankIssued는 payment_status를 ready로 유지하므로 isPaid 역확인)
@@ -83,12 +83,12 @@ class UserVbankMockDepositController
?? (is_array($meta) ? ($meta['tno'] ?? '') : '');
return response()->json([
'available' => true,
'trade_no' => $tradeNo,
'account_no' => $payment->vbank_number,
'notify_url' => url(self::VBANK_NOTIFY_PATH),
'mock_url' => self::KCP_TEST_MOCK_URL,
'is_admin_view' => $isAdmin,
'available' => true,
'trade_no' => $tradeNo,
'account_no' => $payment->vbank_number,
'notify_url' => url(self::VBANK_NOTIFY_PATH),
'mock_url' => self::KCP_TEST_MOCK_URL,
'is_admin_view' => $isAdmin,
]);
}
}
@@ -7,7 +7,10 @@ namespace Plugins\Sirsoft\PayNhnkcp\Listeners;
use App\Contracts\Extension\HookListenerInterface;
/**
* 이커머스 결제수단 설정 화면에서 NHN KCP 간편결제를 PG 선택 불필요 항목으로 표시한다.
* 이커머스 결제수단 설정 화면에 NHN KCP 테스트모드 경고를 주입한다.
*
* 간편결제의 PG 표시(PG 고정 배지)는 코어 레이아웃이 결제수단 카탈로그의 pg_locked /
* needs_pg 를 읽어 직접 처리하므로 이 리스너가 관여하지 않는다(#475).
*/
class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterface
{
@@ -24,16 +27,6 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
private const TEST_MODE_CONDITION = 'nhnkcp_test_mode_status.data?.is_test_mode === true';
private const CORE_NO_PG_METHODS = ['point', 'deposit', 'free', 'dbank'];
private const NHNKCP_NO_PG_METHODS = [
'nhnkcp_payco',
'nhnkcp_naverpay',
'nhnkcp_naverpay_point',
'nhnkcp_kakaopay',
'nhnkcp_applepay',
];
/**
* 이 리스너가 구독하는 훅 정의를 반환합니다.
*
@@ -43,7 +36,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
{
return [
'core.layout_extension.after_apply' => [
'method' => 'markEasyPayMethodsAsPgNotRequired',
'method' => 'adjustPaymentMethodsLayout',
'type' => 'filter',
'priority' => 30,
],
@@ -58,78 +51,30 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
public function handle(...$args): void {}
/**
* 이커머스 결제수단 설정 레이아웃에 NHN KCP 간편결제를 PG 선택 불필요 항목으로 반영합니다.
* 이커머스 결제수단 설정 레이아웃에 테스트모드 경고를 주입합니다.
*
* 과거에는 코어 레이아웃의 "PG 불필요 결제수단" 하드코딩 배열 표현식을 정규식으로 찾아
* 간편결제를 그 목록에 덧붙여 PG 선택 셀렉트를 숨겼다. 그러나 간편결제는 "PG 불필요" 가
* 아니라 "PG 고정" 이며, 이 표시 왜곡은 서버가 간편결제를 PG 없는 주문으로 오인하던
* 결함과 짝을 이뤘다(#475). 정규식 치환은 플러그인이 여럿 설치되면 서로의 결과 위에
* 누적 적용되어 충돌할 위험도 있었다.
*
* 이제 결제수단 카탈로그가 `pg_locked` / `needs_pg` 를 내려주고 코어 레이아웃이 그 값으로
* 직접 3분기(PG 고정 배지 / PG 선택 / PG 불필요)하므로 치환이 불필요하다.
*
* @param array<string, mixed> $layout 대상 레이아웃 정의
* @param int $templateId 레이아웃이 속한 템플릿 ID
* @return array<string, mixed> 보정된 레이아웃 정의
*/
public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array
public function adjustPaymentMethodsLayout(array $layout, int $templateId): array
{
if (($layout['layout_name'] ?? '') !== self::TARGET_LAYOUT) {
return $layout;
}
$layout = $this->replaceNoPgMethodExpressions($layout);
return $this->ensureTestModeWarning($layout);
}
/**
* 레이아웃 노드를 재귀 순회하며 PG 불필요 결제수단 표현식에 NHN KCP 간편결제를 추가합니다.
*
* @param array<string, mixed> $node 순회 대상 노드
* @return array<string, mixed> 치환이 반영된 노드
*/
private function replaceNoPgMethodExpressions(array $node): array
{
foreach ($node as $key => $value) {
if (is_array($value)) {
$node[$key] = $this->replaceNoPgMethodExpressions($value);
continue;
}
if (is_string($value)) {
$node[$key] = $this->addNhnKcpMethodsToNoPgArray($value);
}
}
return $node;
}
/**
* 코어 PG 불필요 결제수단 배열 표현식을 찾아 NHN KCP 간편결제 수단을 덧붙입니다.
*
* @param string $expression 대상 표현식
* @return string NHN KCP 수단이 추가된 표현식 (대상이 아니면 원본 그대로)
*/
private function addNhnKcpMethodsToNoPgArray(string $expression): string
{
return preg_replace_callback(
'/\[((?:\'[^\']+\'\s*,?\s*)+)\]\.includes\(\$method\.id\)/',
function (array $matches): string {
preg_match_all("/'([^']+)'/", $matches[1], $tokenMatches);
$ids = $tokenMatches[1] ?? [];
foreach (self::CORE_NO_PG_METHODS as $coreId) {
if (! in_array($coreId, $ids, true)) {
return $matches[0];
}
}
foreach (self::NHNKCP_NO_PG_METHODS as $methodId) {
if (! in_array($methodId, $ids, true)) {
$ids[] = $methodId;
}
}
return "['".implode("','", $ids)."'].includes(\$method.id)";
},
$expression
) ?? $expression;
}
/**
* 테스트 모드 경고에 필요한 데이터 소스와 안내 노드를 레이아웃에 보장합니다.
*
@@ -15,6 +15,11 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
{
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
/**
* 이 플러그인이 제공하는 PG 식별자 (RegisterPgProviderListener 의 provider id 와 일치).
*/
private const PG_PROVIDER_ID = 'nhnkcp';
private const EASY_PAY_METHODS = [
[
'id' => 'nhnkcp_payco',
@@ -63,12 +68,12 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
}
/**
* @param mixed ...$args
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* @param array<int, array<string, mixed>> $methods
* @param array<int, array<string, mixed>> $methods
* @return array<int, array<string, mixed>>
*/
public function injectEasyPayMethods(array $methods): array
@@ -102,7 +107,7 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
}
/**
* @param array<int, array<string, mixed>> $methods
* @param array<int, array<string, mixed>> $methods
*/
private function resolveInsertionIndex(array $methods): ?int
{
@@ -143,12 +148,19 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
'en' => __($descriptionKey, [], 'en'),
],
'icon' => $icon,
'source' => 'plugin:' . self::PLUGIN_IDENTIFIER,
'source' => 'plugin:'.self::PLUGIN_IDENTIFIER,
'defaults' => [
'pg_provider' => null,
// 간편결제는 NHN KCP 결제창을 통해서만 처리되므로 PG 를 자기 자신으로 고정한다.
// null 로 두면 코어가 PG 없는 결제수단으로 오인해 결제 실패 주문에 관리자 알림이
// 발송되고 임시주문이 삭제되어 재결제가 막힌다(#475).
'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',
'mileage_deduction_timing' => 'payment_complete',
],
];
}
@@ -54,6 +54,10 @@ class RegisterPgProviderListener implements HookListenerInterface
'name' => localized_label(nameKey: 'sirsoft-pay_nhnkcp::provider.name'),
'icon' => 'credit-card',
'supported_methods' => ['card', 'bank_transfer', 'virtual_account', 'mobile'],
// 코어의 provider-agnostic 결제 진입 dispatch — 주문 응답의 pg_payment_handler 로
// 내려가 템플릿이 그대로 호출한다. 미선언 시 프론트가 응답을 변조해 결제창을
// 직접 띄우는 우회가 필요해진다(#475).
'payment_handler' => 'sirsoft-pay_nhnkcp.requestPayment',
];
return $providers;
@@ -81,7 +85,7 @@ class RegisterPgProviderListener implements HookListenerInterface
$liveSuffix = $settings['live_site_cd'] ?? '';
$liveSiteCd = str_starts_with($liveSuffix, self::LIVE_SITE_CD_PREFIX)
? $liveSuffix
: self::LIVE_SITE_CD_PREFIX . $liveSuffix;
: self::LIVE_SITE_CD_PREFIX.$liveSuffix;
$siteCd = $isTest
? ($settings['test_site_cd'] ?? 'T0000')
@@ -92,11 +96,11 @@ class RegisterPgProviderListener implements HookListenerInterface
: 'https://pay.kcp.co.kr/plugin/payplus_web.jsp';
$easyPayKeys = [
'PAYCO' => 'easy_pay_payco',
'NAVERPAY' => 'easy_pay_naverpay',
'PAYCO' => 'easy_pay_payco',
'NAVERPAY' => 'easy_pay_naverpay',
'NAVERPAY POINT' => 'easy_pay_naverpay_point',
'KAKAOPAY' => 'easy_pay_kakaopay',
'APPLEPAY' => 'easy_pay_applepay',
'KAKAOPAY' => 'easy_pay_kakaopay',
'APPLEPAY' => 'easy_pay_applepay',
];
$enabledEasyPays = array_values(array_keys(array_filter($easyPayKeys, fn ($key) => (bool) ($settings[$key] ?? false))));
@@ -18,14 +18,17 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
$hooks['core.layout_extension.after_apply']['type'] ?? null
);
$this->assertSame(
'markEasyPayMethodsAsPgNotRequired',
'adjustPaymentMethodsLayout',
$hooks['core.layout_extension.after_apply']['method'] ?? null
);
}
public function test_marks_nhnkcp_easy_pay_methods_as_pg_not_required_in_admin_ecommerce_settings(): void
public function test_does_not_string_substitute_pg_branch_expressions(): void
{
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
// 코어 레이아웃은 이제 pg_locked / needs_pg 로 직접 분기한다(#475).
// 리스너가 표현식을 치환하면 코어의 분기 로직을 훼손하고, 플러그인이 여럿 설치되면
// 서로의 치환 결과 위에 누적 적용되어 충돌한다.
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
$layout = [
'layout_name' => 'admin_ecommerce_settings',
@@ -33,34 +36,26 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
[
'type' => 'basic',
'name' => 'Select',
'if' => "{{!['point','deposit','free','dbank'].includes(\$method.id) && (_local.form?.available_pg_providers ?? []).length > 0}}",
],
[
'type' => 'composite',
'name' => 'Toggle',
'props' => [
'disabled' => "{{_computed.isReadOnly || !['point','deposit','free','dbank','kginicis_naverpay'].includes(\$method.id) && !\$method.pg_provider && !_local.form?.order_settings?.default_pg_provider}}",
],
'if' => '{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length > 0}}',
],
],
];
$result = $listener->markEasyPayMethodsAsPgNotRequired($layout, 1);
$result = $listener->adjustPaymentMethodsLayout($layout, 1);
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
$this->assertIsString($json);
$this->assertStringContainsString('kginicis_naverpay', $json);
$this->assertStringContainsString('nhnkcp_payco', $json);
$this->assertStringContainsString('nhnkcp_naverpay', $json);
$this->assertStringContainsString('nhnkcp_naverpay_point', $json);
$this->assertStringContainsString('nhnkcp_kakaopay', $json);
$this->assertStringContainsString('nhnkcp_applepay', $json);
$this->assertStringNotContainsString("['point','deposit','free','dbank'].includes", $json);
// 코어 분기 표현식이 그대로 보존되어야 한다.
$this->assertStringContainsString('$method.pg_locked', $json);
$this->assertStringContainsString('$method.needs_pg', $json);
// 간편결제 ID 를 표현식에 끼워 넣지 않는다.
$this->assertStringNotContainsString('nhnkcp_naverpay', $json);
$this->assertStringNotContainsString('nhnkcp_payco', $json);
}
public function test_adds_test_mode_warning_to_order_settings_tab(): void
{
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
$layout = [
'layout_name' => 'admin_ecommerce_settings',
@@ -75,8 +70,8 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
],
];
$result = $listener->markEasyPayMethodsAsPgNotRequired($layout, 1);
$result = $listener->markEasyPayMethodsAsPgNotRequired($result, 1);
$result = $listener->adjustPaymentMethodsLayout($layout, 1);
$result = $listener->adjustPaymentMethodsLayout($result, 1);
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
$this->assertIsString($json);
@@ -99,7 +94,7 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
public function test_leaves_other_layouts_unchanged(): void
{
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
$layout = [
'layout_name' => 'shop/checkout',
@@ -110,6 +105,6 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
],
];
$this->assertSame($layout, $listener->markEasyPayMethodsAsPgNotRequired($layout, 1));
$this->assertSame($layout, $listener->adjustPaymentMethodsLayout($layout, 1));
}
}
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Unit\Listeners;
use Plugins\Sirsoft\PayNhnkcp\Listeners\RegisterEasyPayMethodsListener;
use Plugins\Sirsoft\PayNhnkcp\Listeners\AdjustEcommercePaymentMethodsLayoutListener;
use Plugins\Sirsoft\PayNhnkcp\Listeners\RegisterEasyPayMethodsListener;
use Plugins\Sirsoft\PayNhnkcp\Plugin;
use Tests\TestCase;
@@ -23,7 +23,7 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
public function test_injects_easy_pay_methods_after_phone_and_existing_easy_pay_methods(): void
{
$listener = new RegisterEasyPayMethodsListener();
$listener = new RegisterEasyPayMethodsListener;
$methods = $listener->injectEasyPayMethods([
['id' => 'card'],
@@ -51,15 +51,20 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
public function test_plugin_registers_easy_pay_hook_listeners(): void
{
$listeners = (new Plugin())->getHookListeners();
$listeners = (new Plugin)->getHookListeners();
$this->assertContains(RegisterEasyPayMethodsListener::class, $listeners);
$this->assertContains(AdjustEcommercePaymentMethodsLayoutListener::class, $listeners);
}
public function test_easy_pay_methods_do_not_require_pg_provider_in_saved_defaults(): void
public function test_easy_pay_methods_are_locked_to_own_pg_provider(): void
{
$listener = new RegisterEasyPayMethodsListener();
// 간편결제는 NHN KCP 결제창을 통해서만 처리되므로 PG 를 자기 자신으로 고정 선언한다.
//
// 과거에는 pg_provider 를 null 로 두었고(= "PG 없는 결제수단"), 그 결과 서버가
// 간편결제 주문을 PG 결제가 아닌 주문으로 오인해 (a) 결제 실패했는데 관리자에게
// 신규주문 알림이 발송되고 (b) 임시주문이 즉시 삭제되어 재결제가 불가능해졌다(#475).
$listener = new RegisterEasyPayMethodsListener;
$methods = $listener->injectEasyPayMethods([
['id' => 'phone'],
@@ -75,15 +80,22 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
foreach ($easyPayMethods as $method) {
$this->assertArrayHasKey('defaults', $method);
$this->assertNull($method['defaults']['pg_provider'] ?? null);
// PG 고정 — null 이면 코어가 PG 없는 주문으로 오인한다.
$this->assertSame('nhnkcp', $method['defaults']['pg_provider'] ?? null);
$this->assertTrue($method['defaults']['pg_locked'] ?? false);
$this->assertTrue($method['defaults']['needs_pg'] ?? false);
$this->assertSame('pg', $method['defaults']['refund_method'] ?? null);
$this->assertFalse($method['defaults']['is_active'] ?? true);
$this->assertSame('payment_complete', $method['defaults']['stock_deduction_timing'] ?? null);
$this->assertSame('payment_complete', $method['defaults']['mileage_deduction_timing'] ?? null);
}
}
public function test_easy_pay_method_labels_match_admin_payment_method_names(): void
{
$listener = new RegisterEasyPayMethodsListener();
$listener = new RegisterEasyPayMethodsListener;
$methods = $listener->injectEasyPayMethods([
['id' => 'phone'],
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Unit\Upgrades;
use App\Extension\UpgradeContext;
use App\Upgrades\Data\Ext\Plugins\SirsoftPayNhnkcp\V1_0_0_beta_4\Migrations\BackfillEasyPayPgProvider;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
require_once dirname(__DIR__, 3).'/upgrades/data/1.0.0-beta.4/migrations/BackfillEasyPayPgProvider.php';
/**
* 저장된 주문설정의 NHN KCP 간편결제 PG 고정 백필 테스트 — 이슈 #475
*
* 기존 설치 환경의 order_settings.json 에는 간편결제가 `pg_provider: null` 로 영속화되어
* 있다. 서버가 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인해 결제 실패
* 시 관리자 알림 오발송 + 임시주문 삭제(재결제 불가) 를 일으켰다.
*/
class BackfillEasyPayPgProviderTest extends TestCase
{
private string $settingsPath;
private bool $hadOriginalSettings = false;
private ?string $originalSettings = null;
protected function setUp(): void
{
parent::setUp();
$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();
}
/**
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
*
* @effects upgrade_step_backfills_settings_file, saved_null_pg_provider_self_healed
*/
public function test_backfills_pg_declaration_for_nhnkcp_easy_pay_methods(): void
{
$this->writeSettings([
['id' => 'card', 'pg_provider' => 'nhnkcp'],
['id' => 'nhnkcp_naverpay', 'pg_provider' => null, 'is_active' => true],
['id' => 'nhnkcp_applepay', 'pg_provider' => null, 'is_active' => true],
]);
$this->runMigration();
$methods = $this->readMethods();
foreach (['nhnkcp_naverpay', 'nhnkcp_applepay'] as $id) {
$method = $methods[$id];
$this->assertSame('nhnkcp', $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
{
$this->writeSettings([
['id' => 'card', 'pg_provider' => 'nhnkcp'],
['id' => 'dbank', 'pg_provider' => ''],
['id' => 'kginicis_naverpay', 'pg_provider' => null],
['id' => 'nhnkcp_naverpay', 'pg_provider' => null],
]);
$this->runMigration();
$methods = $this->readMethods();
$this->assertSame('nhnkcp', $methods['card']['pg_provider']);
$this->assertArrayNotHasKey('pg_locked', $methods['card']);
$this->assertSame('', $methods['dbank']['pg_provider']);
$this->assertArrayNotHasKey('pg_locked', $methods['dbank']);
$this->assertNull($methods['kginicis_naverpay']['pg_provider']);
$this->assertArrayNotHasKey('pg_locked', $methods['kginicis_naverpay']);
$this->assertSame('nhnkcp', $methods['nhnkcp_naverpay']['pg_provider']);
}
/**
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
*
* @effects upgrade_step_backfills_settings_file
*/
public function test_is_idempotent(): void
{
$this->writeSettings([
['id' => 'nhnkcp_naverpay', '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));
}
/**
* @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 BackfillEasyPayPgProvider)->run(new UpgradeContext(
fromVersion: '1.0.0-beta.3',
toVersion: '1.0.0-beta.4',
currentStep: '1.0.0-beta.4',
));
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* v1.0.0-beta.4 업그레이드 스텝
*
* 저장된 이커머스 주문설정의 NHN KCP 간편결제 항목에 PG 고정 선언을 백필한다.
*
* 모든 비즈니스 로직은 data/1.0.0-beta.4/migrations/ 로 격리(AbstractUpgradeStep 규약).
*
* @upgrade-path B
*/
class Upgrade_1_0_0_beta_4 extends AbstractUpgradeStep {}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayNhnkcp\V1_0_0_beta_4\Migrations;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\File;
/**
* 저장된 이커머스 주문설정의 NHN KCP 간편결제 항목에 PG 고정 선언을 백필한다.
*
* 배경 (이슈 #475):
*
* 간편결제 수단은 과거 `pg_provider: null` 로 등록되어 저장 파일에 그대로 영속화됐다.
* 서버는 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인했고, 그 결과
* (a) 결제가 실패했는데 관리자에게 신규주문 알림이 발송되고 (b) 임시주문이 즉시 삭제되어
* 재결제가 불가능해졌다.
*
* 현재 코드는 결제수단 카탈로그를 병합할 때 능력 선언(pg_provider / pg_locked / needs_pg /
* refund_method)을 플러그인 정의에서 가져오므로, 저장 파일에 남은 낡은 값이 있어도 런타임
* 동작은 이미 정상이다(자가 치유). 본 마이그레이션은 저장 파일 자체를 현재 선언과 일치시켜
* 설정 화면과 파일 내용이 어긋나 보이지 않게 한다.
*
* V-1 안전 격리 (docs/extension/upgrade-step-guide.md §13):
* - 파일 시스템 + FilePermissionHelper 만 사용 (이전 버전에도 존재하던 표면)
* - Service / Manager / Repository 컨테이너 해석 없음
*/
final class BackfillEasyPayPgProvider implements DataMigration
{
/**
* 이커머스 모듈의 주문설정 저장 경로.
*/
private const SETTINGS_PATH = 'app/modules/sirsoft-ecommerce/settings/order_settings.json';
/**
* 이 플러그인이 등록하는 간편결제 수단의 ID 접두사.
*/
private const METHOD_PREFIX = 'nhnkcp_';
/**
* 이 플러그인이 제공하는 PG 식별자.
*/
private const PG_PROVIDER_ID = 'nhnkcp';
/**
* 마이그레이션 식별자 (로그용).
*
* @return string 사람이 읽을 수 있는 짧은 식별자
*/
public function name(): string
{
return 'BackfillEasyPayPgProvider';
}
/**
* 저장된 주문설정의 간편결제 항목에 PG 고정 선언을 백필한다. idempotent.
*
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등)
*/
public function run(UpgradeContext $context): void
{
$path = storage_path(self::SETTINGS_PATH);
if (! File::exists($path)) {
$context->logger->info('[nhnkcp] 이커머스 주문설정 파일 없음 — 기본값으로 동작하므로 skip');
return;
}
$settings = json_decode(File::get($path), true);
if (! is_array($settings) || ! is_array($settings['payment_methods'] ?? null)) {
$context->logger->warning('[nhnkcp] 주문설정 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('[nhnkcp] 간편결제 PG 선언이 이미 최신 — 변경 없음');
return;
}
File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
FilePermissionHelper::inheritOwnershipFromParent($path);
$context->logger->info('[nhnkcp] 간편결제 PG 고정 선언 백필 완료', [
'pg_provider' => self::PG_PROVIDER_ID,
'updated_methods' => $updated,
]);
}
}
@@ -8,7 +8,16 @@
### Fixed
- 플러그인이 활성화되어 있으면 홈, 게시판, 로그인 등 결제와 무관한 모든 페이지에서 주문서 조회 요청이 한 번씩 불필요하게 발생하던 문제를 수정했습니다. 장바구니가 없는 비회원에게는 이 요청이 매번 실패해 브라우저 오류로 기록됐습니다. 이제 결제 페이지에서만 요청합니다.
- 플러그인이 활성화되어 있으면 홈, 게시판, 로그인 등 결제와 무관한 모든 페이지에서 주문서 조회 요청이 한 번씩 불필요하게 발생하던 문제를 수정했습니다. 장바구니가 없는 비회원에게는 이 요청이 매번 실패해 브라우저 오류로 기록됐습니다. 이제 이 요청 자체가 발생하지 않습니다.
- 네이버페이·카카오페이·삼성페이·애플페이 등 나이스페이먼츠 간편결제로 주문할 때, 결제가 실패했는데도 관리자에게 "신규 주문 접수" 알림이 발송되던 문제를 수정했습니다.
- 간편결제로 결제하다 실패한 뒤 다시 결제하려 하면 "임시주문을 찾을 수 없습니다" 오류가 뜨며 재결제가 불가능하던 문제를 수정했습니다. 이제 결제 실패 후에도 주문서가 유지됩니다.
- 간편결제 주문을 취소할 때 카드 취소가 함께 이뤄지지 않을 수 있던 문제를 수정했습니다.
### Changed
- 간편결제가 정식 결제수단으로 취급되어 주문 내역에 실제로 선택한 결제수단(예: 네이버페이)이 그대로 기록됩니다.
- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 고정 · 나이스페이먼츠"로 표시됩니다. 간편결제는 나이스페이먼츠 전용이므로 다른 결제대행사로 바꿀 수 없습니다.
- 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화됩니다.
### Security
File diff suppressed because one or more lines are too long
@@ -1,9 +1,17 @@
/**
* setPaymentMethod 핸들러 테스트
*
* 결제수단 선택 시 _local 상태에 paymentMethod / serverPaymentMethod 를 분리 저장하는
* 동작을 검증합니다. 간편결제(`nicepay_*`)는 G7 내부적으로 카드로 취급되어
* `serverPaymentMethod = 'card'` 가 되어야 합니다.
* 결제수단 선택 시 _local 상태에 선택한 결제수단 ID 를 **그대로** 저장하는 동작을 검증합니다.
*
* 과거에는 간편결제(`nicepay_*`)를 서버로 보낼 때 'card' 로 위장해야 했기 때문에
* `serverPaymentMethod` 를 함께 저장했습니다. 서버 검증(PaymentMethodEnum)이 확장 ID 를
* 422 로 막았기 때문입니다. 그 위장이 서버로 하여금 간편결제 주문을 "PG 결제가 아닌 주문"
* 으로 오인하게 만들어, 결제 실패 시 관리자 알림 오발송 + 임시주문 삭제(재결제 불가)를
* 일으켰습니다.
*
* 이제 확장 결제수단이 1급 시민이므로 위장이 없고, `serverPaymentMethod` 도 제거됐습니다.
*
* @see https://github.com/gnuboard/dev-g7/issues/475
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { setPaymentMethodHandler } from '../../handlers/setPaymentMethod';
@@ -27,50 +35,34 @@ describe('setPaymentMethodHandler', () => {
expect(setLocalSpy).not.toHaveBeenCalled();
});
it('일반 결제수단(card)은 paymentMethod와 serverPaymentMethod에 동일 값 저장', () => {
it('일반 결제수단(card)은 그대로 저장', () => {
setPaymentMethodHandler({ params: { paymentMethod: 'card' } });
expect(setLocalSpy).toHaveBeenCalledWith({
paymentMethod: 'card',
serverPaymentMethod: 'card',
});
expect(setLocalSpy).toHaveBeenCalledWith({ paymentMethod: 'card' });
});
it('가상계좌(vbank)도 그대로 분리 저장', () => {
it('가상계좌(vbank)도 그대로 저장', () => {
setPaymentMethodHandler({ params: { paymentMethod: 'vbank' } });
expect(setLocalSpy).toHaveBeenCalledWith({
paymentMethod: 'vbank',
serverPaymentMethod: 'vbank',
});
expect(setLocalSpy).toHaveBeenCalledWith({ paymentMethod: 'vbank' });
});
describe('간편결제 (nicepay_*)', () => {
it('네이버페이는 serverPaymentMethod=card 로 정규화', () => {
describe('간편결제 (nicepay_*) — 확장 ID 를 위장하지 않는다', () => {
it.each([
['네이버페이', 'nicepay_naverpay'],
['카카오페이', 'nicepay_kakaopay'],
['애플페이', 'nicepay_applepay'],
])('%s 는 확장 ID 그대로 저장된다', (_label, method) => {
setPaymentMethodHandler({ params: { paymentMethod: method } });
expect(setLocalSpy).toHaveBeenCalledWith({ paymentMethod: method });
});
it('serverPaymentMethod 를 더 이상 저장하지 않는다 (card 위장 제거)', () => {
setPaymentMethodHandler({ params: { paymentMethod: 'nicepay_naverpay' } });
expect(setLocalSpy).toHaveBeenCalledWith({
paymentMethod: 'nicepay_naverpay',
serverPaymentMethod: 'card',
});
});
it('카카오페이도 serverPaymentMethod=card', () => {
setPaymentMethodHandler({ params: { paymentMethod: 'nicepay_kakaopay' } });
expect(setLocalSpy).toHaveBeenCalledWith({
paymentMethod: 'nicepay_kakaopay',
serverPaymentMethod: 'card',
});
});
it('애플페이도 serverPaymentMethod=card', () => {
setPaymentMethodHandler({ params: { paymentMethod: 'nicepay_applepay' } });
expect(setLocalSpy).toHaveBeenCalledWith({
paymentMethod: 'nicepay_applepay',
serverPaymentMethod: 'card',
});
const saved = setLocalSpy.mock.calls[0][0] as Record<string, unknown>;
expect(saved).not.toHaveProperty('serverPaymentMethod');
});
});
@@ -1,181 +1,104 @@
/**
* 주문 생성 인터셉터 테스트 — 이슈 #475
*
* 확장 결제수단이 1급 시민이 되면서 인터셉터의 우회가 전부 제거됐다:
* - payment_method 를 'card' 로 위장 (서버 검증이 확장 ID 를 422 로 막았기 때문)
* - 응답의 requires_pg_payment / redirect_url 변조 (navigate-to-self 강제)
* - pg_payment_data 재구성
* - navigate-to-self 로 인한 "주문서 없음" 다이얼로그를 막기 위한 GET checkout 캐싱
*
* 위장이 서버로 하여금 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인하게 만들어
* (a) 결제 실패 시 관리자 알림 오발송 (b) 임시주문 삭제 → 재결제 불가 를 일으켰다.
*
* 이제 결제창 진입은 서버 응답의 pg_payment_handler 를 템플릿이 dispatch 하는 경로로
* 처리되고, 템플릿의 PG 분기는 navigate 를 하지 않으므로 캐싱도 불필요하다.
*
* 본 테스트는 인터셉터가 fetch 를 일절 건드리지 않음(no-op)을 회귀 방지로 고정한다.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { installOrderResponseInterceptor } from '../orderResponseInterceptor';
import { requestPaymentHandler } from '../handlers/requestPayment';
vi.mock('../handlers/requestPayment', () => ({
requestPaymentHandler: vi.fn(),
}));
const requestPaymentMock = vi.mocked(requestPaymentHandler);
function resetNicepayWindowFlags(): void {
const w = window as unknown as Record<string, unknown>;
delete w.__sirsoftNicepayFetchInterceptorInstalled;
delete w.__sirsoftPgOriginalFetch;
delete w.__sirsoftNicepayPaymentInProgress;
delete w.__sirsoftNicepayCheckoutCache;
delete w.__sirsoftNicepayAxiosCheckoutInterceptorInstalled;
}
/** 현재 경로를 지정한다 (jsdom 은 history API 로만 pathname 변경 가능) */
function setPathname(pathname: string): void {
window.history.replaceState({}, '', pathname);
}
/**
* G7Core.api 스파이를 설치하고 get 스파이를 반환한다.
*
* 캐시 프리페치는 tryInstallAxiosCheckoutInterceptor 안에 있고, 이 함수는
* `G7Core.api.client`(Axios)가 준비될 때까지 재시도하다가 준비되면 프리페치한다.
* 따라서 client(=interceptors 를 가진 객체)를 함께 제공해야 실제 경로를 탄다.
*/
function installCoreApiSpy(): ReturnType<typeof vi.fn> {
const getSpy = vi.fn().mockResolvedValue({});
const axiosClient = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
};
(window as unknown as Record<string, unknown>).G7Core = {
api: { get: getSpy, client: axiosClient },
};
return getSpy;
}
function buildOrderResponse(order: Record<string, unknown>): Response {
return new Response(JSON.stringify({
success: true,
data: {
order,
requires_pg_payment: false,
redirect_url: '/shop/orders/ORD-NICE-001/complete',
},
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const ORDER_CREATE_URL = '/api/modules/sirsoft-ecommerce/user/orders';
const CHECKOUT_URL = '/api/modules/sirsoft-ecommerce/checkout';
describe('installOrderResponseInterceptor', () => {
let originalFetch: typeof window.fetch;
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
originalFetch = window.fetch;
fetchSpy = vi.fn();
(window as unknown as Record<string, unknown>).fetch = fetchSpy;
resetNicepayWindowFlags();
requestPaymentMock.mockClear();
window.history.replaceState({}, '', '/shop/checkout');
vi.spyOn(console, 'info').mockImplementation(() => {});
});
afterEach(() => {
window.fetch = originalFetch;
resetNicepayWindowFlags();
delete (window as unknown as Record<string, unknown>).G7Core;
setPathname('/');
vi.restoreAllMocks();
});
describe('체크아웃 캐시 프리페치 라우트 가드', () => {
// 플러그인 번들은 모든 페이지에 로드된다. 캐시 프리페치는 체크아웃 페이지의
// navigate-to-self 재초기화를 위한 것이므로, 다른 라우트에서 발화하면
// 비회원에게 무의미한 422(cart_key_required) 왕복만 발생시킨다.
it.each([
['홈', '/'],
['게시판', '/board'],
['로그인', '/login'],
['상품 상세', '/shop/products/1'],
['장바구니', '/shop/cart'],
])('%s 에서는 체크아웃을 프리페치하지 않는다', async (_label, pathname) => {
setPathname(pathname);
const getSpy = installCoreApiSpy();
installOrderResponseInterceptor();
await vi.waitFor(() => expect(true).toBe(true));
expect(getSpy).not.toHaveBeenCalled();
});
it('체크아웃 페이지에서는 캐시 프리페치가 동작한다', async () => {
setPathname('/shop/checkout');
const getSpy = installCoreApiSpy();
installOrderResponseInterceptor();
await vi.waitFor(() => {
expect(getSpy).toHaveBeenCalledWith('/modules/sirsoft-ecommerce/checkout');
});
});
it('이미 캐시가 있으면 체크아웃 페이지에서도 재요청하지 않는다', async () => {
setPathname('/shop/checkout');
(window as unknown as Record<string, unknown>).__sirsoftNicepayCheckoutCache =
JSON.stringify({ data: {} });
const getSpy = installCoreApiSpy();
installOrderResponseInterceptor();
await vi.waitFor(() => expect(true).toBe(true));
expect(getSpy).not.toHaveBeenCalled();
});
});
it('간편결제 fallback pg_payment_data 금액은 total_due_amount를 우선 사용', async () => {
fetchSpy.mockResolvedValue(buildOrderResponse({
order_number: 'ORD-NICE-001',
total_amount: '57000.00',
total_due_amount: '53000.00',
options: [{ product_name: '테스트 상품' }],
orderer_name: '홍길동',
orderer_email: 'buyer@example.com',
orderer_phone: '010-1234-5678',
user_id: 7,
}));
it('window.fetch 를 래핑하지 않는다 (no-op)', () => {
const originalFetch = vi.fn();
window.fetch = originalFetch as unknown as typeof fetch;
installOrderResponseInterceptor();
await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
method: 'POST',
body: JSON.stringify({ payment_method: 'nicepay_kakaopay' }),
});
expect(requestPaymentMock).toHaveBeenCalledWith({
params: {
pgPaymentData: expect.objectContaining({
order_number: 'ORD-NICE-001',
order_name: '테스트 상품',
amount: 53000,
customer_phone: '01012345678',
}),
paymentMethod: 'nicepay_kakaopay',
},
});
// 래핑하면 다른 함수 참조가 된다 — 동일 참조여야 한다.
expect(window.fetch).toBe(originalFetch);
});
it('total_due_amount가 없는 구형 응답은 total_amount로 fallback', async () => {
fetchSpy.mockResolvedValue(buildOrderResponse({
order_number: 'ORD-NICE-002',
total_amount: '57000.00',
options: [{ product_name: '테스트 상품' }],
}));
it('주문 생성 요청의 payment_method 를 위장하지 않는다', async () => {
let sentBody = '';
window.fetch = vi.fn().mockImplementation(async (_input: unknown, init?: RequestInit) => {
sentBody = String(init?.body ?? '');
return new Response('{}', { status: 200 });
}) as unknown as typeof fetch;
installOrderResponseInterceptor();
await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
await window.fetch(ORDER_CREATE_URL, {
method: 'POST',
body: JSON.stringify({ payment_method: 'nicepay_naverpay' }),
});
expect(requestPaymentMock).toHaveBeenCalledWith({
params: {
pgPaymentData: expect.objectContaining({
order_number: 'ORD-NICE-002',
amount: 57000,
}),
paymentMethod: 'nicepay_naverpay',
expect(JSON.parse(sentBody).payment_method).toBe('nicepay_naverpay');
});
it('주문 생성 응답을 변조하지 않는다', async () => {
const serverBody = {
success: true,
data: {
order: { order_number: 'ORD-1' },
redirect_url: '/shop/orders/ORD-1/complete',
requires_pg_payment: true,
pg_provider: 'sirsoft-nicepayments',
pg_payment_handler: 'sirsoft-pay_nicepayments.requestPayment',
},
});
};
window.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify(serverBody), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
) as unknown as typeof fetch;
installOrderResponseInterceptor();
const response = await window.fetch(ORDER_CREATE_URL, { method: 'POST', body: '{}' });
const body = await response.json();
expect(body.data.requires_pg_payment).toBe(true);
expect(body.data.redirect_url).toBe('/shop/orders/ORD-1/complete');
expect(body.data.pg_payment_handler).toBe('sirsoft-pay_nicepayments.requestPayment');
});
it('GET checkout 응답을 캐싱하거나 가로채지 않는다', async () => {
const checkoutResponse = new Response('{"data":{}}', { status: 200 });
const fetchSpy = vi.fn().mockResolvedValue(checkoutResponse);
window.fetch = fetchSpy as unknown as typeof fetch;
installOrderResponseInterceptor();
const first = await window.fetch(CHECKOUT_URL, { method: 'GET' });
const second = await window.fetch(CHECKOUT_URL, { method: 'GET' });
// 캐시 서빙이 부활하면 두 번째 호출이 네트워크로 나가지 않는다.
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(first).toBe(checkoutResponse);
expect(second).toBe(checkoutResponse);
});
});
@@ -38,6 +38,11 @@ interface PgPaymentData {
order_name: string;
amount: number;
currency?: string;
/**
* 서버가 저장한 결제수단 ID — 확장 수단이면 확장 ID 그대로(예: 'nicepay_naverpay').
* 확장 결제수단이 1급 시민이 되면서 서버가 원본 수단을 알고 내려준다(#475).
*/
payment_method?: string;
customer_name?: string;
customer_email?: string;
customer_phone?: string;
@@ -175,7 +180,11 @@ export async function requestPaymentHandler(action: PaymentAction, _context?: un
const { pgPaymentData, paymentMethod: paramPaymentMethod } = action.params ?? {};
const localState = window.__templateApp?.globalState?._local;
const paymentMethod = paramPaymentMethod ?? localState?.paymentMethod ?? 'card';
// 서버가 저장한 결제수단(payment_method)이 SSoT — 확장 수단이면 확장 ID 가 그대로 온다(#475).
const paymentMethod = paramPaymentMethod
?? pgPaymentData?.payment_method
?? localState?.paymentMethod
?? 'card';
if (!pgPaymentData) {
console.error('[sirsoft-pay_nicepayments] pgPaymentData is required');
@@ -55,11 +55,11 @@ export function setPaymentMethodHandler(action: any): void {
const paymentMethod = action.params?.paymentMethod;
if (!paymentMethod) return;
// 확장 결제수단은 1급 시민 — 선택한 수단 ID 를 그대로 서버에 보낸다.
// (과거에는 서버 검증이 확장 ID 를 422 로 막아 'card' 로 정규화한 serverPaymentMethod 를
// 함께 저장했으나, 소비처가 없는 죽은 코드였고 위장 자체가 결함의 원인이었다 — #475)
const isEasyPay = typeof paymentMethod === 'string' && paymentMethod.indexOf('nicepay_') === 0;
(window as any).G7Core?.state?.setLocal?.({
paymentMethod,
serverPaymentMethod: isEasyPay ? 'card' : paymentMethod,
});
(window as any).G7Core?.state?.setLocal?.({ paymentMethod });
if (isEasyPay) {
updateEasyPayButtonStyles(paymentMethod);
@@ -1,342 +1,49 @@
/**
* 주문 생성 API 요청/응답 인터셉터
* 주문 생성 요청/응답 인터셉터 — 제거됨 (no-op)
*
* G7Core 템플릿 엔진의 apiCall 핸들러는 window.fetch 를 직접 사용하므로
* window.fetch 를 래핑하는 방식으로 동작한다.
* 과거에는 이 인터셉터가 다음 우회를 수행했다:
*
* 1. POST /api/modules/sirsoft-ecommerce/user/orders 요청을 가로챈다
* 2. payment_method 가 'nicepay_*' 이면 'card' 로 교체해 서버에 전송
* (서버의 PaymentMethodEnum 에 nicepay_* 가 없어서 422 발생 방지)
* 3. 응답에서 pg_provider === 'sirsoft-nicepayments' 이면 requestPayment 핸들러를 직접 호출
* 4. 원래 nicepay 방식(paymentMethod)을 requestPayment 에 전달해 올바른 결제창 호출
* 5. requires_pg_payment → false, redirect_url → 현재 URL 로 교체
* → 템플릿 조건 분기가 !requires_pg_payment 쪽으로 떨어져 navigate-to-self 가 됨
* 1. 요청 body 의 `payment_method` 를 'card' 로 위장 (서버 검증이 확장 ID 를 422 로 막았기 때문)
* 2. 응답의 `requires_pg_payment` 를 false 로, `redirect_url` 을 self 로 변조해
* 템플릿의 navigate 를 navigate-to-self 로 무력화하고 결제창을 직접 띄움
* 3. `pg_payment_data` 를 order 응답에서 직접 재구성
* 4. navigate-to-self 로 체크아웃이 재초기화될 때 뜨는 "주문서 없음" 다이얼로그를
* 억제하기 위한 GET checkout 응답 캐싱 (fetch + axios 양쪽)
*
* 이 우회는 두 개의 심각한 결함을 낳았다 — 서버가 간편결제 주문을 "PG 결제가 아닌 주문"
* 으로 오인해 (a) 결제 실패했는데 관리자에게 신규주문 알림이 발송되고 (b) 임시주문이
* 즉시 삭제되어 재결제가 불가능해졌다.
*
* 이제 코어가 확장 결제수단을 1급 시민으로 처리한다:
* - 서버 검증이 결제수단 카탈로그를 화이트리스트로 사용 (확장 ID 통과)
* - 플러그인이 `pg_provider` 를 자기 PG 로 고정 선언 → PG 결제 주문으로 정상 판정
* - provider 가 선언한 `payment_handler` 가 응답의 `pg_payment_handler` 로 내려가고,
* 템플릿이 그 핸들러를 직접 dispatch 한다 (navigate 분기 자체가 실행되지 않음)
*
* 템플릿이 PG 분기에서 navigate 를 하지 않으므로 navigate-to-self 도, 그로 인한
* 체크아웃 재초기화도 발생하지 않는다. 따라서 GET checkout 캐싱도 불필요하다.
*
* 파일과 export 는 호출부(index.ts) 호환을 위해 유지하되 동작은 no-op 이다.
*
* @see https://github.com/gnuboard/dev-g7/issues/475
*/
import { requestPaymentHandler } from './handlers/requestPayment';
const ORDER_CREATE_PATH = '/user/orders';
const TARGET_PG_PROVIDER = 'sirsoft-nicepayments';
const PLUGIN_IDENTIFIER = 'sirsoft-pay_nicepayments';
const FETCH_FLAG = '__sirsoftNicepayFetchInterceptorInstalled';
const CHECKOUT_ENDPOINT_PATH = '/api/modules/sirsoft-ecommerce/checkout';
const PAYMENT_IN_PROGRESS_FLAG = '__sirsoftNicepayPaymentInProgress';
const CHECKOUT_CACHE_KEY = '__sirsoftNicepayCheckoutCache';
/** 체크아웃 페이지 경로 (캐시 프리페치를 이 경로에서만 수행) */
const CHECKOUT_ROUTE_RE = /^\/shop\/checkout\/?$/;
const logger = {
info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),
warn: (...args: unknown[]) => console.warn(`[${PLUGIN_IDENTIFIER}]`, ...args),
error: (...args: unknown[]) => console.error(`[${PLUGIN_IDENTIFIER}]`, ...args),
};
function buildNoOpRedirectUrl(): string {
return window.location.pathname + window.location.search + window.location.hash;
}
function isTargetOrderEndpoint(url: string, method: string): boolean {
if (method.toUpperCase() !== 'POST') return false;
const path = (url ?? '').split('?')[0].split('#')[0];
return path === ORDER_CREATE_PATH || path.endsWith(ORDER_CREATE_PATH);
}
function isCheckoutEndpoint(url: string, method: string): boolean {
if (method.toUpperCase() !== 'GET') return false;
const path = (url ?? '').split('?')[0].split('#')[0];
return path === CHECKOUT_ENDPOINT_PATH || path.endsWith(CHECKOUT_ENDPOINT_PATH);
}
/** 현재 브라우저 경로가 체크아웃 페이지인지 판정한다. */
function isCheckoutRoute(): boolean {
return CHECKOUT_ROUTE_RE.test(window.location.pathname);
}
function extractPaymentMethodFromBody(body: unknown): string | undefined {
if (!body) return undefined;
let obj: Record<string, unknown> | null = null;
if (typeof body === 'string') {
try { obj = JSON.parse(body) as Record<string, unknown>; } catch { return undefined; }
} else if (typeof body === 'object') {
obj = body as Record<string, unknown>;
}
if (!obj) return undefined;
const v = obj['payment_method'];
return typeof v === 'string' && v.length > 0 ? v : undefined;
}
function replacePaymentMethodInBody(body: string, newMethod: string): string {
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
if ('payment_method' in parsed) {
return JSON.stringify({ ...parsed, payment_method: newMethod });
}
} catch { /* fall through */ }
return body;
}
function resolveOrderPaymentAmount(orderData: Record<string, unknown>): number {
const candidates = [orderData.total_due_amount, orderData.total_amount];
for (const candidate of candidates) {
const amount = Number(candidate ?? 0);
if (Number.isFinite(amount) && amount > 0) {
return Math.floor(amount);
}
}
return 0;
}
/**
* 주문 생성 인터셉터 — 더 이상 fetch 를 래핑하지 않는다.
*
* 결제창 진입은 코어 응답의 `pg_payment_handler` 를 템플릿이 dispatch 하는 경로로 처리된다.
*/
export function installOrderResponseInterceptor(): void {
const w = window as unknown as Record<string, unknown>;
if (w[FETCH_FLAG]) return;
w[FETCH_FLAG] = true;
// 최초 설치 플러그인이 원본 브라우저 fetch를 보존 (다른 PG 인터셉터가 쌓이기 전)
// easy pay 요청 시 이 fetch를 사용해 다른 PG 인터셉터(NHN KCP 등)의 간섭을 차단한다
const ORIGINAL_FETCH_KEY = '__sirsoftPgOriginalFetch';
if (!w[ORIGINAL_FETCH_KEY]) {
w[ORIGINAL_FETCH_KEY] = window.fetch.bind(window);
}
const browserFetch = w[ORIGINAL_FETCH_KEY] as typeof fetch;
const originalFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: (input as Request).url;
const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
// GET checkout 캐싱 — 결제 진행 중 페이지 재초기화 시 "주문서 없음" 다이얼로그 억제
if (isCheckoutEndpoint(url, method)) {
if (w[PAYMENT_IN_PROGRESS_FLAG] && w[CHECKOUT_CACHE_KEY]) {
logger.info('payment in progress — serving cached checkout response');
return new Response(w[CHECKOUT_CACHE_KEY] as string, {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const checkoutResp = await originalFetch(input, init);
if (checkoutResp.ok) {
const text = await checkoutResp.text();
w[CHECKOUT_CACHE_KEY] = text;
return new Response(text, {
status: checkoutResp.status,
statusText: checkoutResp.statusText,
headers: checkoutResp.headers,
});
}
return checkoutResp;
}
if (!isTargetOrderEndpoint(url, method)) {
return originalFetch(input, init);
}
// 요청 body에서 payment_method 추출. nicepay_* (간편결제) 만 'card' 로 교체.
// 일반 결제수단(card / vbank / bank / phone)도 그대로 보존해 requestPaymentHandler
// 호출 시 정확한 결제수단으로 결제창을 연다 (이전엔 nicepay_* 만 추적해서
// 가상계좌/계좌이체/휴대폰 선택 시 'card' 로 폴백되는 회귀가 있었음).
let originalPaymentMethod: string | undefined;
let isEasyPay = false;
let modifiedInit = init;
if (init?.body && typeof init.body === 'string') {
const pm = extractPaymentMethodFromBody(init.body);
if (typeof pm === 'string' && pm.length > 0) {
originalPaymentMethod = pm;
if (pm.startsWith('nicepay_')) {
isEasyPay = true;
modifiedInit = { ...init, body: replacePaymentMethodInBody(init.body, 'card') };
logger.info(`easy pay detected: replacing payment_method '${pm}' → 'card'`);
}
}
}
// easy pay일 때는 browserFetch(원본)를 사용해 NHN KCP 등 다른 PG 인터셉터를 우회한다.
// "타 PG와 사용가능함"이 ON이고 기본 PG가 NHN KCP일 때 NHN KCP 결제창이 먼저 열리는 것을 방지.
const fetchFn = isEasyPay ? browserFetch : originalFetch;
const response = await fetchFn(input, modifiedInit);
// 2xx 응답만 처리 (4xx/5xx 오류 응답은 그대로 통과)
if (!response.ok) {
return response;
}
// 응답 파싱 (clone으로 원본 스트림 보호)
// 서버 응답 구조: { success, message, data: { order, requires_pg_payment, pg_provider, pg_payment_data, redirect_url } }
let envelope: Record<string, unknown> | null = null;
try {
envelope = (await response.clone().json()) as Record<string, unknown>;
} catch {
return response;
}
const responseData = (envelope?.data ?? envelope) as Record<string, unknown> | null;
// 일반 결제: PG 결제가 필요 없으면 통과
// 간편결제(nicepay_*): requires_pg_payment=false여도 계속 처리
// → 기본 PG가 미설정이면 백엔드가 requires_pg_payment:false를 반환하지만
// 간편결제는 나이스페이먼츠 결제창을 열어야 하므로 통과시킴 (취약점 방어)
if (!responseData?.requires_pg_payment && !isEasyPay) {
return response;
}
// easy pay: pg_provider 무관하게 나이스페이 처리
// 일반 결제: pg_provider가 나이스페이인 경우에만 처리 (나이스페이가 기본 PG일 때)
const isNicepayPg = responseData.pg_provider === TARGET_PG_PROVIDER;
if (!isEasyPay && !isNicepayPg) {
return response;
}
// pg_payment_data: 백엔드 응답에 포함되거나,
// 간편결제 + 기본 PG 미설정 시 order 데이터에서 직접 구성
let pgPaymentData = responseData.pg_payment_data as Record<string, unknown> | undefined;
if (!pgPaymentData && isEasyPay) {
const orderData = responseData.order as Record<string, unknown> | undefined;
if (orderData) {
const options = orderData.options as Array<Record<string, unknown>> | undefined;
const firstName = (options?.[0]?.product_name as string | undefined) ?? String(orderData.order_number ?? '');
const orderName = (options?.length ?? 0) > 1
? `${firstName} 외 ${(options?.length ?? 0) - 1}건`
: firstName;
pgPaymentData = {
order_number: orderData.order_number,
order_name: orderName,
// PG 청구액 SSoT는 total_due_amount. 구형 응답 호환만 total_amount로 fallback.
amount: resolveOrderPaymentAmount(orderData),
currency: 'KRW',
customer_name: orderData.orderer_name ?? null,
customer_email: orderData.orderer_email ?? null,
customer_phone: String(orderData.orderer_phone ?? '').replace(/[^0-9]/g, ''),
customer_key: orderData.user_id ?? null,
};
logger.info('pg_payment_data constructed from order (기본 PG 미설정)', {
order_number: pgPaymentData.order_number,
amount: pgPaymentData.amount,
});
}
}
if (!pgPaymentData) {
logger.warn('nicepayments order detected but pg_payment_data missing');
return response;
}
const paymentMethod = originalPaymentMethod ?? 'card';
logger.info('intercepted order create response — opening PG popup', { paymentMethod });
void requestPaymentHandler({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params: { pgPaymentData: pgPaymentData as any, paymentMethod },
});
// 결제 진행 중 플래그 설정 — GET checkout 캐시 서빙으로 "주문서 없음" 다이얼로그 억제
w[PAYMENT_IN_PROGRESS_FLAG] = true;
// 결제창(팝업)이 열려 있는 동안 checkout 페이지에 머물도록
// redirect_url을 항상 현재 URL(navigate-to-self)로 교체한다.
// → 템플릿 fallback navigate가 무력화되어 "결제 완료" 페이지로 이동하지 않음
// → 실제 결제 완료 후 requestPaymentHandler 콜백이 complete 페이지로 이동시킴
// navigate-to-self로 checkout 재초기화 시 GET checkout 404 → "주문서 없음" 다이얼로그가
// 뜰 수 있으나, PAYMENT_IN_PROGRESS_FLAG + 캐시 서빙으로 억제된다.
// envelope.data 안에 있는 경우와 최상위에 있는 경우 모두 처리
const modifiedData = { ...responseData, requires_pg_payment: false, redirect_url: buildNoOpRedirectUrl() };
const modifiedBody = envelope?.data
? { ...envelope, data: modifiedData }
: modifiedData;
return new Response(JSON.stringify(modifiedBody), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
};
logger.info('fetch order interceptor installed');
// Axios 인터셉터 설치 — GET checkout 캐싱 및 결제 중 캐시 서빙
// G7Core.api.client(Axios)가 초기화된 후 설치해야 하므로 비동기로 재시도
tryInstallAxiosCheckoutInterceptor(w, 15);
logger.info('order interceptor is a no-op — payment entry is dispatched via pg_payment_handler');
}
const AXIOS_CHECKOUT_FLAG = '__sirsoftNicepayAxiosCheckoutInterceptorInstalled';
function tryInstallAxiosCheckoutInterceptor(w: Record<string, unknown>, retries: number): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const axiosClient = ((window as any)?.G7Core?.api?.client) as any;
if (!axiosClient?.interceptors) {
if (retries > 0) {
setTimeout(() => tryInstallAxiosCheckoutInterceptor(w, retries - 1), 300);
}
return;
}
if (w[AXIOS_CHECKOUT_FLAG]) return;
w[AXIOS_CHECKOUT_FLAG] = true;
// 응답 인터셉터: GET checkout 성공 시 캐시 저장
// Axios config.url은 baseURL을 포함하지 않으므로 (config.baseURL ?? '') + config.url 로 완성
// eslint-disable-next-line @typescript-eslint/no-explicit-any
axiosClient.interceptors.response.use((response: any) => {
const method = (response?.config?.method ?? '').toLowerCase();
const url = String((response?.config?.baseURL ?? '') + (response?.config?.url ?? ''));
if (method === 'get' && isCheckoutEndpoint(url, 'GET') && response?.status === 200) {
w[CHECKOUT_CACHE_KEY] = JSON.stringify(response.data);
logger.info('checkout response cached (axios)');
}
return response;
});
// 요청 인터셉터: 결제 진행 중 GET checkout을 캐시에서 반환
// eslint-disable-next-line @typescript-eslint/no-explicit-any
axiosClient.interceptors.request.use((config: any) => {
const method = (config?.method ?? '').toLowerCase();
const url = String((config?.baseURL ?? '') + (config?.url ?? ''));
if (method === 'get' && isCheckoutEndpoint(url, 'GET') && w[PAYMENT_IN_PROGRESS_FLAG] && w[CHECKOUT_CACHE_KEY]) {
logger.info('payment in progress — serving cached checkout response (axios)');
// adapter를 Promise.resolve로 교체해 실제 요청 없이 캐시 반환
config.adapter = () => Promise.resolve({
data: JSON.parse(w[CHECKOUT_CACHE_KEY] as string),
status: 200,
statusText: 'OK (cached)',
headers: { 'content-type': 'application/json' },
config,
request: {},
});
}
return config;
});
logger.info('axios checkout interceptor installed');
// 인터셉터 설치 직후 GET checkout 즉시 캐싱 — navigate-to-self 재초기화 대비
//
// 체크아웃 페이지에서만 수행한다. 플러그인 번들은 모든 페이지에 로드되므로 경로를
// 가리지 않으면 홈/게시판/로그인 등 어디서나 이 요청이 나가고, 장바구니가 없는
// 비회원은 매 페이지 422(cart_key_required)를 받는다. 캐시는 체크아웃 페이지의
// navigate-to-self 재초기화 대비용이므로 다른 경로에서는 만들 이유가 없다.
if (isCheckoutRoute() && !w[CHECKOUT_CACHE_KEY]) {
void (async () => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const coreApi = ((window as any)?.G7Core?.api) as { get?: (url: string) => Promise<unknown> } | undefined;
await coreApi?.get?.('/modules/sirsoft-ecommerce/checkout');
// 응답은 Axios 응답 인터셉터가 자동으로 캐싱함
} catch { /* 체크아웃 세션 없으면 실패 — 무시 */ }
})();
}
}
/** @deprecated Axios 인터셉터 방식 — apiCall 핸들러가 window.fetch 를 사용하므로 동작하지 않음. installOrderResponseInterceptor 를 사용할 것. */
/** @deprecated Axios 인터셉터 방식 — 사용하지 않는다. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function installAxiosOrderInterceptor(_axiosClient: any): void {
// no-op
@@ -30,8 +30,7 @@ class AdminVbankNotificationController
{
public function __construct(
private readonly OrderProcessingService $orderService
) {
}
) {}
/**
* 가상계좌 입금통보 이력 조회
@@ -55,7 +54,7 @@ class AdminVbankNotificationController
}
$payment = $order->payment;
if (! $payment || $payment->payment_method?->value !== 'vbank') {
if (! $payment || ! $payment->isVirtualAccount()) {
return response()->json([
'success' => true,
'data' => ['notifications' => [], 'summary' => null],
@@ -12,7 +12,6 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
@@ -182,7 +181,7 @@ class AdminVbankRefundController extends AdminBaseController
private function isRefundableVbankPayment(OrderPayment $payment, string $tid): bool
{
return $payment->pg_provider === 'nicepayments'
&& $payment->payment_method === PaymentMethodEnum::VBANK
&& $payment->isVirtualAccount()
&& $payment->payment_status === PaymentStatusEnum::PAID
&& $payment->paid_at !== null
&& $payment->vbank_number !== null
@@ -8,7 +8,6 @@ use App\Contracts\Extension\CacheInterface;
use App\Extension\HookManager;
use App\Services\PluginSettingsService;
use Carbon\Carbon;
use InvalidArgumentException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -16,6 +15,7 @@ use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use InvalidArgumentException;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
@@ -23,8 +23,8 @@ use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayNicepayments\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayNicepayments\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayNicepayments\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayNicepayments\Concerns\RecordsPaymentWindowClosure;
use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay;
use Plugins\Sirsoft\PayNicepayments\Concerns\SanitizesPgResponse;
@@ -42,8 +42,8 @@ use Plugins\Sirsoft\PayNicepayments\Support\UrlHelper;
*/
class PaymentCallbackController
{
use PreventsReplayCallback;
use IssuesReceiptCookie;
use PreventsReplayCallback;
use RecordsPaymentWindowClosure;
use ResolvesEasyPayDisplay;
use SanitizesPgResponse;
@@ -875,7 +875,7 @@ class PaymentCallbackController
string $tid,
int $amount,
): ?string {
if (! in_array($payment->payment_method?->value, ['vbank', 'virtual_account'], true)) {
if (! $payment->isVirtualAccount()) {
return 'payment_method_not_vbank';
}
@@ -7,7 +7,10 @@ namespace Plugins\Sirsoft\PayNicepayments\Listeners;
use App\Contracts\Extension\HookListenerInterface;
/**
* 이커머스 결제수단 설정 화면에서 나이스페이먼츠 간편결제를 PG 선택 불필요 항목으로 표시한다.
* 이커머스 결제수단 설정 화면에 나이스페이먼츠 테스트모드 경고를 주입한다.
*
* 간편결제의 PG 표시(PG 고정 배지)는 코어 레이아웃이 결제수단 카탈로그의 pg_locked /
* needs_pg 를 읽어 직접 처리하므로 이 리스너가 관여하지 않는다(#475).
*/
class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterface
{
@@ -24,19 +27,6 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
private const TEST_MODE_CONDITION = 'nicepay_test_mode_status.data?.is_test_mode === true';
private const CORE_NO_PG_METHODS = ['point', 'deposit', 'free', 'dbank'];
private const NICEPAY_NO_PG_METHODS = [
'nicepay_naverpay',
'nicepay_kakaopay',
'nicepay_samsungpay',
'nicepay_applepay',
'nicepay_payco',
'nicepay_skpay',
'nicepay_ssgpay',
'nicepay_lpay',
];
/**
* 이 리스너가 구독하는 훅 정의를 반환합니다.
*
@@ -46,7 +36,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
{
return [
'core.layout_extension.after_apply' => [
'method' => 'markEasyPayMethodsAsPgNotRequired',
'method' => 'adjustPaymentMethodsLayout',
'type' => 'filter',
'priority' => 40,
],
@@ -59,78 +49,30 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
public function handle(...$args): void {}
/**
* 이커머스 결제수단 설정 레이아웃에 나이스페이먼츠 간편결제를 PG 선택 불필요 항목으로 반영합니다.
* 이커머스 결제수단 설정 레이아웃에 테스트모드 경고를 주입합니다.
*
* 과거에는 코어 레이아웃의 "PG 불필요 결제수단" 하드코딩 배열 표현식을 정규식으로 찾아
* 간편결제를 그 목록에 덧붙여 PG 선택 셀렉트를 숨겼다. 그러나 간편결제는 "PG 불필요" 가
* 아니라 "PG 고정" 이며, 이 표시 왜곡은 서버가 간편결제를 PG 없는 주문으로 오인하던
* 결함과 짝을 이뤘다(#475). 정규식 치환은 플러그인이 여럿 설치되면 서로의 결과 위에
* 누적 적용되어 충돌할 위험도 있었다.
*
* 이제 결제수단 카탈로그가 `pg_locked` / `needs_pg` 를 내려주고 코어 레이아웃이 그 값으로
* 직접 3분기(PG 고정 배지 / PG 선택 / PG 불필요)하므로 치환이 불필요하다.
*
* @param array<string, mixed> $layout 대상 레이아웃 정의
* @param int $templateId 레이아웃이 속한 템플릿 ID
* @return array<string, mixed> 보정된 레이아웃 정의
*/
public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array
public function adjustPaymentMethodsLayout(array $layout, int $templateId): array
{
if (($layout['layout_name'] ?? '') !== self::TARGET_LAYOUT) {
return $layout;
}
$layout = $this->replaceNoPgMethodExpressions($layout);
return $this->ensureTestModeWarning($layout);
}
/**
* 레이아웃 노드를 재귀 순회하며 PG 불필요 결제수단 표현식에 나이스페이먼츠 간편결제를 추가합니다.
*
* @param array<string, mixed> $node 순회 대상 노드
* @return array<string, mixed> 치환이 반영된 노드
*/
private function replaceNoPgMethodExpressions(array $node): array
{
foreach ($node as $key => $value) {
if (is_array($value)) {
$node[$key] = $this->replaceNoPgMethodExpressions($value);
continue;
}
if (is_string($value)) {
$node[$key] = $this->addNicepayMethodsToNoPgArray($value);
}
}
return $node;
}
/**
* 코어 PG 불필요 결제수단 배열 표현식을 찾아 나이스페이먼츠 간편결제 수단을 덧붙입니다.
*
* @param string $expression 대상 표현식
* @return string 나이스페이먼츠 수단이 추가된 표현식 (대상이 아니면 원본 그대로)
*/
private function addNicepayMethodsToNoPgArray(string $expression): string
{
return preg_replace_callback(
'/\[((?:\'[^\']+\'\s*,?\s*)+)\]\.includes\(\$method\.id\)/',
function (array $matches): string {
preg_match_all("/'([^']+)'/", $matches[1], $tokenMatches);
$ids = $tokenMatches[1] ?? [];
foreach (self::CORE_NO_PG_METHODS as $coreId) {
if (! in_array($coreId, $ids, true)) {
return $matches[0];
}
}
foreach (self::NICEPAY_NO_PG_METHODS as $methodId) {
if (! in_array($methodId, $ids, true)) {
$ids[] = $methodId;
}
}
return "['".implode("','", $ids)."'].includes(\$method.id)";
},
$expression
) ?? $expression;
}
/**
* 테스트 모드 경고에 필요한 데이터 소스와 안내 노드를 레이아웃에 보장합니다.
*
@@ -15,6 +15,11 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
{
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nicepayments';
/**
* 이 플러그인이 제공하는 PG 식별자 (RegisterPgProviderListener 의 provider id 와 일치).
*/
private const PG_PROVIDER_ID = 'nicepayments';
private const EASY_PAY_METHODS = [
[
'id' => 'nicepay_naverpay',
@@ -81,12 +86,12 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
}
/**
* @param mixed ...$args
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* @param array<int, array<string, mixed>> $methods
* @param array<int, array<string, mixed>> $methods
* @return array<int, array<string, mixed>>
*/
public function injectEasyPayMethods(array $methods): array
@@ -120,7 +125,7 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
}
/**
* @param array<int, array<string, mixed>> $methods
* @param array<int, array<string, mixed>> $methods
*/
private function resolveInsertionIndex(array $methods): ?int
{
@@ -165,12 +170,19 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
'en' => __($descriptionKey, [], 'en'),
],
'icon' => $icon,
'source' => 'plugin:' . self::PLUGIN_IDENTIFIER,
'source' => 'plugin:'.self::PLUGIN_IDENTIFIER,
'defaults' => [
'pg_provider' => null,
// 간편결제는 나이스페이먼츠 결제창을 통해서만 처리되므로 PG 를 자기 자신으로 고정한다.
// null 로 두면 코어가 PG 없는 결제수단으로 오인해 결제 실패 주문에 관리자 알림이
// 발송되고 임시주문이 삭제되어 재결제가 막힌다(#475).
'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',
'mileage_deduction_timing' => 'payment_complete',
],
];
}
@@ -52,6 +52,10 @@ class RegisterPgProviderListener implements HookListenerInterface
'name' => localized_label(nameKey: 'sirsoft-pay_nicepayments::provider.name'),
'icon' => 'credit-card',
'supported_methods' => ['card', 'bank_transfer', 'virtual_account', 'mobile'],
// 코어의 provider-agnostic 결제 진입 dispatch — 주문 응답의 pg_payment_handler 로
// 내려가 템플릿이 그대로 호출한다. 미선언 시 프론트가 응답을 변조해 결제창을
// 직접 띄우는 우회가 필요해진다(#475).
'payment_handler' => 'sirsoft-pay_nicepayments.requestPayment',
];
return $providers;
@@ -125,6 +129,6 @@ class RegisterPgProviderListener implements HookListenerInterface
return '';
}
return str_starts_with($suffix, 'SR') ? $suffix : 'SR' . $suffix;
return str_starts_with($suffix, 'SR') ? $suffix : 'SR'.$suffix;
}
}
@@ -18,14 +18,18 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
$hooks['core.layout_extension.after_apply']['type'] ?? null
);
$this->assertSame(
'markEasyPayMethodsAsPgNotRequired',
'adjustPaymentMethodsLayout',
$hooks['core.layout_extension.after_apply']['method'] ?? null
);
}
public function test_marks_nicepay_easy_pay_methods_as_pg_not_required_in_admin_ecommerce_settings(): void
public function test_does_not_string_substitute_pg_branch_expressions(): void
{
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
// 코어 레이아웃은 이제 pg_locked / needs_pg 로 직접 분기한다(#475).
// 리스너가 표현식을 치환하면 코어의 분기 로직을 훼손하고, 플러그인이 여럿 설치되면
// 서로의 치환 결과 위에 누적 적용되어 충돌한다 (과거 이 테스트의 fixture 에
// kginicis / nhnkcp 의 ID 가 이미 섞여 있던 것이 그 증거다).
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
$layout = [
'layout_name' => 'admin_ecommerce_settings',
@@ -33,38 +37,26 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
[
'type' => 'basic',
'name' => 'Select',
'if' => "{{!['point','deposit','free','dbank'].includes(\$method.id) && (_local.form?.available_pg_providers ?? []).length > 0}}",
],
[
'type' => 'composite',
'name' => 'Toggle',
'props' => [
'disabled' => "{{_computed.isReadOnly || !['point','deposit','free','dbank','kginicis_naverpay','nhnkcp_payco'].includes(\$method.id) && !\$method.pg_provider && !_local.form?.order_settings?.default_pg_provider}}",
],
'if' => '{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length > 0}}',
],
],
];
$result = $listener->markEasyPayMethodsAsPgNotRequired($layout, 1);
$result = $listener->adjustPaymentMethodsLayout($layout, 1);
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
$this->assertIsString($json);
$this->assertStringContainsString('kginicis_naverpay', $json);
$this->assertStringContainsString('nhnkcp_payco', $json);
$this->assertStringContainsString('nicepay_naverpay', $json);
$this->assertStringContainsString('nicepay_kakaopay', $json);
$this->assertStringContainsString('nicepay_samsungpay', $json);
$this->assertStringContainsString('nicepay_applepay', $json);
$this->assertStringContainsString('nicepay_payco', $json);
$this->assertStringContainsString('nicepay_skpay', $json);
$this->assertStringContainsString('nicepay_ssgpay', $json);
$this->assertStringContainsString('nicepay_lpay', $json);
$this->assertStringNotContainsString("['point','deposit','free','dbank'].includes", $json);
// 코어 분기 표현식이 그대로 보존되어야 한다.
$this->assertStringContainsString('$method.pg_locked', $json);
$this->assertStringContainsString('$method.needs_pg', $json);
// 간편결제 ID 를 표현식에 끼워 넣지 않는다.
$this->assertStringNotContainsString('nicepay_naverpay', $json);
$this->assertStringNotContainsString('nicepay_kakaopay', $json);
}
public function test_adds_test_mode_warning_to_order_settings_tab(): void
{
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
$layout = [
'layout_name' => 'admin_ecommerce_settings',
@@ -79,8 +71,8 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
],
];
$result = $listener->markEasyPayMethodsAsPgNotRequired($layout, 1);
$result = $listener->markEasyPayMethodsAsPgNotRequired($result, 1);
$result = $listener->adjustPaymentMethodsLayout($layout, 1);
$result = $listener->adjustPaymentMethodsLayout($result, 1);
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
$this->assertIsString($json);
@@ -103,7 +95,7 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
public function test_leaves_other_layouts_unchanged(): void
{
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
$layout = [
'layout_name' => 'shop/checkout',
@@ -114,6 +106,6 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
],
];
$this->assertSame($layout, $listener->markEasyPayMethodsAsPgNotRequired($layout, 1));
$this->assertSame($layout, $listener->adjustPaymentMethodsLayout($layout, 1));
}
}
@@ -23,7 +23,7 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
public function test_injects_easy_pay_methods_after_existing_pg_easy_pay_methods(): void
{
$listener = new RegisterEasyPayMethodsListener();
$listener = new RegisterEasyPayMethodsListener;
$methods = $listener->injectEasyPayMethods([
['id' => 'card'],
@@ -54,15 +54,20 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
public function test_plugin_registers_easy_pay_hook_listeners(): void
{
$listeners = (new Plugin())->getHookListeners();
$listeners = (new Plugin)->getHookListeners();
$this->assertContains(RegisterEasyPayMethodsListener::class, $listeners);
$this->assertContains(AdjustEcommercePaymentMethodsLayoutListener::class, $listeners);
}
public function test_easy_pay_methods_do_not_require_pg_provider_in_saved_defaults(): void
public function test_easy_pay_methods_are_locked_to_own_pg_provider(): void
{
$listener = new RegisterEasyPayMethodsListener();
// 간편결제는 나이스페이먼츠 결제창을 통해서만 처리되므로 PG 를 자기 자신으로 고정 선언한다.
//
// 과거에는 pg_provider 를 null 로 두었고(= "PG 없는 결제수단"), 그 결과 서버가
// 간편결제 주문을 PG 결제가 아닌 주문으로 오인해 (a) 결제 실패했는데 관리자에게
// 신규주문 알림이 발송되고 (b) 임시주문이 즉시 삭제되어 재결제가 불가능해졌다(#475).
$listener = new RegisterEasyPayMethodsListener;
$methods = $listener->injectEasyPayMethods([
['id' => 'phone'],
@@ -78,15 +83,22 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
foreach ($easyPayMethods as $method) {
$this->assertArrayHasKey('defaults', $method);
$this->assertNull($method['defaults']['pg_provider'] ?? null);
// PG 고정 — null 이면 코어가 PG 없는 주문으로 오인한다.
$this->assertSame('nicepayments', $method['defaults']['pg_provider'] ?? null);
$this->assertTrue($method['defaults']['pg_locked'] ?? false);
$this->assertTrue($method['defaults']['needs_pg'] ?? false);
$this->assertSame('pg', $method['defaults']['refund_method'] ?? null);
$this->assertFalse($method['defaults']['is_active'] ?? true);
$this->assertSame('payment_complete', $method['defaults']['stock_deduction_timing'] ?? null);
$this->assertSame('payment_complete', $method['defaults']['mileage_deduction_timing'] ?? null);
}
}
public function test_easy_pay_method_labels_match_admin_payment_method_names(): void
{
$listener = new RegisterEasyPayMethodsListener();
$listener = new RegisterEasyPayMethodsListener;
$methods = $listener->injectEasyPayMethods([
['id' => 'phone'],
@@ -97,14 +109,14 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
$kakaopay = collect($methods)->firstWhere('id', 'nicepay_kakaopay');
$payco = collect($methods)->firstWhere('id', 'nicepay_payco');
$this->assertSame('네이' . "\u{200B}" . '버페이 (나이스페이먼츠)', $naverpay['name']['ko'] ?? null);
$this->assertSame('카카' . "\u{200B}" . '오페이로 결제 (나이스페이먼츠)', $kakaopay['description']['ko'] ?? null);
$this->assertSame('네이'."\u{200B}".'버페이 (나이스페이먼츠)', $naverpay['name']['ko'] ?? null);
$this->assertSame('카카'."\u{200B}".'오페이로 결제 (나이스페이먼츠)', $kakaopay['description']['ko'] ?? null);
$this->assertSame('PAYCO (NicePayments)', $payco['name']['en'] ?? null);
}
public function test_easy_pay_method_labels_avoid_other_pg_brand_matchers(): void
{
$listener = new RegisterEasyPayMethodsListener();
$listener = new RegisterEasyPayMethodsListener;
$methods = $listener->injectEasyPayMethods([
['id' => 'phone'],
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNicepayments\Tests\Unit\Upgrades;
use App\Extension\UpgradeContext;
use App\Upgrades\Data\Ext\Plugins\SirsoftPayNicepayments\V1_0_0_beta_4\Migrations\BackfillEasyPayPgProvider;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
require_once dirname(__DIR__, 3).'/upgrades/data/1.0.0-beta.4/migrations/BackfillEasyPayPgProvider.php';
/**
* 저장된 주문설정의 나이스페이먼츠 간편결제 PG 고정 백필 테스트 — 이슈 #475
*
* 기존 설치 환경의 order_settings.json 에는 간편결제가 `pg_provider: null` 로 영속화되어
* 있다. 서버가 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인해 결제 실패
* 시 관리자 알림 오발송 + 임시주문 삭제(재결제 불가) 를 일으켰다.
*/
class BackfillEasyPayPgProviderTest extends TestCase
{
private string $settingsPath;
private bool $hadOriginalSettings = false;
private ?string $originalSettings = null;
protected function setUp(): void
{
parent::setUp();
$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();
}
/**
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
*
* @effects upgrade_step_backfills_settings_file, saved_null_pg_provider_self_healed
*/
public function test_backfills_pg_declaration_for_nicepay_easy_pay_methods(): void
{
$this->writeSettings([
['id' => 'card', 'pg_provider' => 'nicepayments'],
['id' => 'nicepay_naverpay', 'pg_provider' => null, 'is_active' => true],
['id' => 'nicepay_kakaopay', 'pg_provider' => null, 'is_active' => true],
]);
$this->runMigration();
$methods = $this->readMethods();
foreach (['nicepay_naverpay', 'nicepay_kakaopay'] as $id) {
$method = $methods[$id];
$this->assertSame('nicepayments', $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
{
$this->writeSettings([
['id' => 'card', 'pg_provider' => 'nicepayments'],
['id' => 'dbank', 'pg_provider' => ''],
['id' => 'nhnkcp_naverpay', 'pg_provider' => null],
['id' => 'nicepay_naverpay', 'pg_provider' => null],
]);
$this->runMigration();
$methods = $this->readMethods();
$this->assertSame('nicepayments', $methods['card']['pg_provider']);
$this->assertArrayNotHasKey('pg_locked', $methods['card']);
$this->assertSame('', $methods['dbank']['pg_provider']);
$this->assertArrayNotHasKey('pg_locked', $methods['dbank']);
$this->assertNull($methods['nhnkcp_naverpay']['pg_provider']);
$this->assertArrayNotHasKey('pg_locked', $methods['nhnkcp_naverpay']);
$this->assertSame('nicepayments', $methods['nicepay_naverpay']['pg_provider']);
}
/**
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
*
* @effects upgrade_step_backfills_settings_file
*/
public function test_is_idempotent(): void
{
$this->writeSettings([
['id' => 'nicepay_naverpay', '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));
}
/**
* @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 BackfillEasyPayPgProvider)->run(new UpgradeContext(
fromVersion: '1.0.0-beta.3',
toVersion: '1.0.0-beta.4',
currentStep: '1.0.0-beta.4',
));
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNicepayments\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* v1.0.0-beta.4 업그레이드 스텝
*
* 저장된 이커머스 주문설정의 나이스페이먼츠 간편결제 항목에 PG 고정 선언을 백필한다.
*
* 모든 비즈니스 로직은 data/1.0.0-beta.4/migrations/ 로 격리(AbstractUpgradeStep 규약).
*
* @upgrade-path B
*/
class Upgrade_1_0_0_beta_4 extends AbstractUpgradeStep {}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayNicepayments\V1_0_0_beta_4\Migrations;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\File;
/**
* 저장된 이커머스 주문설정의 나이스페이먼츠 간편결제 항목에 PG 고정 선언을 백필한다.
*
* 배경 (이슈 #475):
*
* 간편결제 수단은 과거 `pg_provider: null` 로 등록되어 저장 파일에 그대로 영속화됐다.
* 서버는 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인했고, 그 결과
* (a) 결제가 실패했는데 관리자에게 신규주문 알림이 발송되고 (b) 임시주문이 즉시 삭제되어
* 재결제가 불가능해졌다.
*
* 현재 코드는 결제수단 카탈로그를 병합할 때 능력 선언(pg_provider / pg_locked / needs_pg /
* refund_method)을 플러그인 정의에서 가져오므로, 저장 파일에 남은 낡은 값이 있어도 런타임
* 동작은 이미 정상이다(자가 치유). 본 마이그레이션은 저장 파일 자체를 현재 선언과 일치시켜
* 설정 화면과 파일 내용이 어긋나 보이지 않게 한다.
*
* V-1 안전 격리 (docs/extension/upgrade-step-guide.md §13):
* - 파일 시스템 + FilePermissionHelper 만 사용 (이전 버전에도 존재하던 표면)
* - Service / Manager / Repository 컨테이너 해석 없음
*/
final class BackfillEasyPayPgProvider implements DataMigration
{
/**
* 이커머스 모듈의 주문설정 저장 경로.
*/
private const SETTINGS_PATH = 'app/modules/sirsoft-ecommerce/settings/order_settings.json';
/**
* 이 플러그인이 등록하는 간편결제 수단의 ID 접두사.
*/
private const METHOD_PREFIX = 'nicepay_';
/**
* 이 플러그인이 제공하는 PG 식별자.
*/
private const PG_PROVIDER_ID = 'nicepayments';
/**
* 마이그레이션 식별자 (로그용).
*
* @return string 사람이 읽을 수 있는 짧은 식별자
*/
public function name(): string
{
return 'BackfillEasyPayPgProvider';
}
/**
* 저장된 주문설정의 간편결제 항목에 PG 고정 선언을 백필한다. idempotent.
*
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등)
*/
public function run(UpgradeContext $context): void
{
$path = storage_path(self::SETTINGS_PATH);
if (! File::exists($path)) {
$context->logger->info('[nicepayments] 이커머스 주문설정 파일 없음 — 기본값으로 동작하므로 skip');
return;
}
$settings = json_decode(File::get($path), true);
if (! is_array($settings) || ! is_array($settings['payment_methods'] ?? null)) {
$context->logger->warning('[nicepayments] 주문설정 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('[nicepayments] 간편결제 PG 선언이 이미 최신 — 변경 없음');
return;
}
File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
FilePermissionHelper::inheritOwnershipFromParent($path);
$context->logger->info('[nicepayments] 간편결제 PG 고정 선언 백필 완료', [
'pg_provider' => self::PG_PROVIDER_ID,
'updated_methods' => $updated,
]);
}
}
@@ -16,6 +16,8 @@
"dev": "npm run safelist && vite build --watch",
"test": "vitest",
"test:run": "vitest run",
"test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts",
"test:e2e:ui": "playwright test --config=tests/Playwright/playwright.config.ts --ui",
"test:coverage": "vitest run --coverage",
"type-check": "tsc --noEmit"
},

Some files were not shown because too many files have changed in this diff Show More