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:
1 parent
10fe28a16d
commit
e896a47d4f
107 files changed
+4002
-1778
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
+101
@@ -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 테이블) 구조 검증 ───
|
||||
|
||||
+5
-3
@@ -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}}');
|
||||
});
|
||||
|
||||
+10
-4
@@ -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": {
|
||||
|
||||
+18
-4
@@ -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"
|
||||
|
||||
+18
-4
@@ -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": [
|
||||
{
|
||||
|
||||
+12
-5
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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'],
|
||||
|
||||
+16
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
+84
@@ -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 연동 없이 취소가 처리된다.
|
||||
*/
|
||||
|
||||
+115
@@ -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. 비회원 주문 생성 시 조회 비밀번호가 해시로 저장된다 (평문 미저장)
|
||||
*/
|
||||
|
||||
+554
@@ -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: {
|
||||
|
||||
+208
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+13
-1
@@ -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.
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user