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 committed 2026-07-15 11:23:51 +09:00
1 parent 10fe28a16d
commit e896a47d4f
107 files changed
+4002 -1778

No files matched your search

+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: {