From 21fc114f37fe085e4d22dc1aefff443df3319636 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Wed, 2 Sep 2026 17:36:11 +0900 Subject: [PATCH] =?UTF-8?q?fix(core,extensions):=20=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=EA=B2=B0=ED=95=A8=203=EA=B1=B4=EA=B3=BC=20=EC=9D=B4=EC=A4=91?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=C2=B7=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=A4=91=EB=B3=B5=ED=82=A4=20=EA=B2=B0=ED=95=A8?= =?UTF-8?q?=EA=B5=B0=20=ED=8F=90=EC=87=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KISA 제보 3건(KVE-2026-2010/2011/2018)과 그 동일 계열 형제 결함을 전수 조치하고, 그 과정에서 드러난 두 결함군을 함께 닫는다. - 검증 시점과 연결 시점이 host 를 다르게 읽던 SSRF 통로를 정규화 SSoT 한 곳으로 모았다 - 세션을 여는 지점(2FA 완료·토큰 재발급)이 잠금 검사를 거치지 않아 계정 잠금이 우회됐다 - 인증도 서명도 없는 브라우저 리턴 콜백이 주문 상태를 바꾸던 통로를 4 PG 전부에서 닫고, 소유권을 대조하는 close-report 를 토스에도 신설했다. 그 결과 정리 주체를 잃는 결제창 미완료 주문은 만료 자동취소가 거둔다 - 저장소 A(_local)에만 쓰는 경로가 B 의 값을 조용히 덮던 회귀를 정본 writer 로 닫았다 (engine-v1.63.5). 한 방향만 보던 정적 검사에 반대 방향 축과 양방향 계약 테스트를 더했다 - 레이아웃 JSON 의 같은 객체 중복 키가 앞선 선언을 오류 없이 삼키던 결함군을 닫았다 --- AGENTS.md | 4 +- CHANGELOG.md | 6 + .../Controllers/Api/Admin/AuthController.php | 13 + .../Controllers/Api/Auth/AuthController.php | 45 +- app/Services/AuthService.php | 59 +- app/Support/OutboundUrlValidator.php | 85 ++- docs/backend/api/README.md | 6 +- docs/backend/api/auth.md | 7 + docs/frontend/layout-json.md | 19 + docs/frontend/state-management.md | 7 + .../CHANGELOG.md | 6 + .../backend/ja/validation.php | 7 + .../frontend/partial/admin/settings.json | 3 + .../language-pack.json | 2 +- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 5 + .../sirsoft-ecommerce/config/ecommerce.php | 4 + .../config/settings/defaults.json | 1 + .../sirsoft-ecommerce/docs/api/settings.md | 2 + .../sirsoft-ecommerce/docs/settings.md | 15 + .../header-currency-selector-user.json | 2 + .../lang/partial/en/admin/settings.json | 5 +- .../lang/partial/ko/admin/settings.json | 5 +- .../admin/admin_ecommerce_product_form.json | 3 +- .../_modal_additional_options_clear.json | 6 +- .../_partial_product_options.json | 9 +- .../_tab_order_settings.json | 58 ++ .../CancelPendingPaymentOrdersCommand.php | 23 +- .../Admin/StoreEcommerceSettingsRequest.php | 5 + .../Contracts/OrderRepositoryInterface.php | 14 +- .../src/Repositories/OrderRepository.php | 51 +- .../src/lang/en/validation.php | 7 + .../src/lang/ko/validation.php | 7 + .../Admin/ShippingPolicyTestApiCallTest.php | 48 ++ .../fixtures/admin-product-lookup.ts | 47 ++ .../fixtures/shop-additional-option-lookup.ts | 186 +++++++ ...oduct-additional-options-roundtrip.spec.ts | 97 ++-- .../product-additional-options-toggle.spec.ts | 64 ++- ...itional-option-currency-conversion.spec.ts | 217 +++++--- .../specs/shop/additional-options.spec.ts | 127 +++-- .../CancelPendingPaymentOrdersCommandTest.php | 162 ++++++ .../pending-order-expiry-cleanup.yaml | 75 +++ .../_bundled/sirsoft-pay_kginicis/AGENTS.md | 11 +- .../sirsoft-pay_kginicis/CHANGELOG.md | 5 + .../sirsoft-pay_kginicis/components.json | 2 +- .../dist/js/plugin.iife.js | 14 +- .../sirsoft-pay_kginicis/docs/api/payment.md | 2 + .../paymentCloseMessageListener.test.ts | 121 +++++ .../resources/js/index.ts | 10 +- .../js/paymentCloseMessageListener.ts | 114 +++- .../src/Controllers/CbtCallbackController.php | 5 +- .../Controllers/MobileCallbackController.php | 6 +- .../Controllers/PaymentCallbackController.php | 7 +- .../PaymentCallbackControllerTest.php | 43 ++ .../tests/Playwright/playwright.config.ts | 102 ++++ .../payment-failure-close-report.spec.ts | 114 ++++ .../scenarios/security-callback-defense.yaml | 39 +- plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md | 5 + .../_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md | 7 + .../sirsoft-pay_nhnkcp/components.json | 2 +- .../sirsoft-pay_nhnkcp/dist/js/plugin.iife.js | 4 +- .../sirsoft-pay_nhnkcp/docs/api/README.md | 1 + .../sirsoft-pay_nhnkcp/docs/api/payment.md | 198 +++++++ .../docs/extension-points.md | 4 +- .../js/__tests__/paymentCloseReport.test.ts | 122 ++++- .../resources/js/handlers/requestPayment.ts | 6 + .../sirsoft-pay_nhnkcp/resources/js/index.ts | 6 + .../resources/js/paymentCloseReport.ts | 127 +++++ .../Controllers/PaymentCallbackController.php | 161 ++++-- .../PaymentCallbackControllerTest.php | 391 ++++++++++---- .../Unit/Services/NhnKcpApiServiceTest.php | 15 +- .../scenarios/security-callback-defense.yaml | 55 +- .../sirsoft-pay_nicepayments/AGENTS.md | 2 + .../sirsoft-pay_nicepayments/CHANGELOG.md | 4 + .../docs/extension-points.md | 4 +- .../src/Services/NicePaymentsApiService.php | 83 ++- .../Services/NicePaymentsApiServiceTest.php | 88 ++- .../scenarios/security-callback-defense.yaml | 22 +- .../_bundled/sirsoft-tosspayments/AGENTS.md | 8 +- .../sirsoft-tosspayments/CHANGELOG.md | 6 + .../sirsoft-tosspayments/components.json | 2 +- .../dist/js/plugin.iife.js | 2 +- .../sirsoft-tosspayments/docs/README.md | 2 +- .../sirsoft-tosspayments/docs/api/payment.md | 84 ++- .../sirsoft-tosspayments/docs/settings.md | 1 + .../js/__tests__/paymentCloseReport.test.ts | 157 ++++++ .../resources/js/handlers/requestPayment.ts | 12 + .../resources/js/index.ts | 6 + .../resources/js/paymentCloseReport.ts | 187 +++++++ .../Concerns/RecordsPaymentWindowClosure.php | 204 +++++++ .../Controllers/PaymentCallbackController.php | 13 +- .../PaymentCloseReportController.php | 130 +++++ .../Requests/PaymentCloseReportRequest.php | 40 ++ .../sirsoft-tosspayments/src/routes/api.php | 20 + .../PaymentCallbackControllerTest.php | 43 +- .../PaymentCloseReportControllerTest.php | 276 ++++++++++ .../tests/PluginTestCase.php | 10 + .../scenarios/security-callback-defense.yaml | 100 ++++ .../Identity/KcpCallbackResolverTest.php | 34 ++ public/build/core/dev-dashboard.css | 2 +- public/build/core/layout-editor.min.js | 8 +- public/build/core/template-engine.min.js | 80 +-- .../core/template-engine/ActionDispatcher.ts | 238 ++++++++- .../js/core/template-engine/CHANGELOG.md | 22 + .../js/core/template-engine/FormContext.tsx | 24 +- .../js/core/template-engine/G7CoreGlobals.ts | 5 + ...tionDispatcher.canonicalLocalWrite.test.ts | 356 +++++++++++++ .../dual-store-mirror-contract.test.tsx | 502 ++++++++++++++++++ .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 1 + .../dist/js/components.iife.js | 2 +- .../sirsoft-admin_basic/docs/handlers.md | 2 +- .../detectAssetUrlModeHandler.test.ts | 10 +- .../src/handlers/detectAssetUrlModeHandler.ts | 11 +- templates/_bundled/sirsoft-basic/CHANGELOG.md | 1 + .../sirsoft-basic/dist/js/components.iife.js | 22 +- .../dist/src/handlers/formatCurrency.d.ts | 20 +- .../dist/src/handlers/getDisplayPrice.d.ts | 29 +- .../src/handlers/loadPreferredCurrency.d.ts | 20 +- .../sirsoft-basic/dist/src/types/index.d.ts | 90 ++-- .../_bundled/sirsoft-basic/docs/handlers.md | 26 +- .../layouts/partials/shop/_cart_item.json | 6 +- .../partials/shop/_checkout_items.json | 2 +- .../partials/shop/_checkout_shipping.json | 3 +- .../shop/_modal_cart_option_change.json | 2 + .../partials/shop/detail/_purchase_card.json | 10 +- .../shop-list-context-round-trip.test.ts | 15 +- .../formatCurrencyConfigured.test.ts | 21 +- .../src/handlers/formatCurrency.ts | 35 +- .../src/handlers/getDisplayPrice.ts | 32 +- .../src/handlers/loadPreferredCurrency.ts | 38 +- .../_bundled/sirsoft-basic/src/types/index.ts | 88 +-- .../specs/shop-cart-option-id.spec.ts | 153 ++++-- tests/Feature/Auth/GatePermissionTest.php | 4 + .../Feature/Auth/TwoFactorAccountLockTest.php | 326 ++++++++++++ .../Security/OutboundRequestGuardTest.php | 58 ++ .../Unit/Support/OutboundUrlValidatorTest.php | 158 ++++++ tests/scenarios/auth-login-throttle.yaml | 22 + .../outbound-url-host-normalization.yaml | 102 ++++ 137 files changed, 6470 insertions(+), 829 deletions(-) create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/admin-product-lookup.ts create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/shop-additional-option-lookup.ts create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/scenarios/pending-order-expiry-cleanup.yaml create mode 100644 plugins/_bundled/sirsoft-pay_kginicis/tests/Playwright/playwright.config.ts create mode 100644 plugins/_bundled/sirsoft-pay_kginicis/tests/Playwright/specs/public/payment-failure-close-report.spec.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/payment.md create mode 100644 plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/paymentCloseReport.test.ts create mode 100644 plugins/_bundled/sirsoft-tosspayments/resources/js/paymentCloseReport.ts create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Concerns/RecordsPaymentWindowClosure.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Controllers/PaymentCloseReportController.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/PaymentCloseReportRequest.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/routes/api.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/PaymentCloseReportControllerTest.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/tests/scenarios/security-callback-defense.yaml create mode 100644 resources/js/core/template-engine/__tests__/ActionDispatcher.canonicalLocalWrite.test.ts create mode 100644 resources/js/core/template-engine/__tests__/dual-store-mirror-contract.test.tsx create mode 100644 tests/Feature/Auth/TwoFactorAccountLockTest.php create mode 100644 tests/scenarios/outbound-url-host-normalization.yaml diff --git a/AGENTS.md b/AGENTS.md index 405a19c4..2a3cc837 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,7 +193,7 @@ | `sirsoft-pay_kginicis` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_kginicis/docs/README.md) | 훅 6 · 라우트 35 · 모델 0 · 레이아웃 1 | | `sirsoft-pay_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/README.md) | 훅 8 · 라우트 16 · 모델 0 · 레이아웃 1 | | `sirsoft-pay_nicepayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_nicepayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_nicepayments/docs/README.md) | 훅 5 · 라우트 15 · 모델 0 · 레이아웃 1 | -| `sirsoft-tosspayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-tosspayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-tosspayments/docs/README.md) | 훅 4 · 라우트 4 · 모델 0 · 레이아웃 1 | +| `sirsoft-tosspayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-tosspayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-tosspayments/docs/README.md) | 훅 4 · 라우트 5 · 모델 0 · 레이아웃 1 | | `sirsoft-verification_kginicis` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_kginicis/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_kginicis/docs/README.md) | 훅 3 · 라우트 2 · 모델 2 · 레이아웃 1 | | `sirsoft-verification_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md) | 훅 0 · 라우트 2 · 모델 2 · 레이아웃 1 | | `gnuboard7-hello_admin_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_admin_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 | @@ -324,6 +324,8 @@ Icon 은 `` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 | 두 쓰기 경로(B 쓰기 / 반환값)에 서로 다른 병합 규칙 | 같은 규칙 — 갈라지면 나중에 소비자가 생길 때 어느 경로를 탔느냐로 결과가 달라진다 | | `__g7ForcedLocalFields` 오버레이가 있으니 `context.state` 도 최신이라고 가정 | 그 오버레이는 `extendedDataContext` **useMemo 안에서 읽는 window 전역**이라 deps 가 아니다 — memo 가 재계산되지 않으면 실리지 않는다 | | 자동바인딩이 `__g7PendingLocalState` 에 저장소 A 스냅샷을 그대로 대입 | 렌더러와 같은 순서로 `__g7ForcedLocalFields` 를 얹고 방금 입력한 경로를 다시 적용 — pending 은 `getLocal()` 이 읽는 "화면과 같은 전체 스냅샷" 이다 | +| 저장소 A 에만 쓰는 `_local` 경로 (`context.setState(payload)` 단독) | 같은 지배 분기 안에서 B 도 갱신 — `G7Core.state.setLocal(payload, { render: false })`. B 에 이미 키가 있으면 보충 대상에서 빠져 A 의 값이 조용히 유실된다 | +| 미러를 **형제 분기**에 두고 이 분기도 지켜진다고 간주 | 미러는 그 쓰기를 **지배하는 분기 안**에 둔다 — 긴 함수를 통째로 보면 한 분기의 미러가 다른 분기를 면죄한다 | A 가 값을 못 받는 대표 경로는 `setLocal({ render: false, selfManaged: true })`(CKEditor 등 자체 DOM 관리 플러그인)다. `render:false` 는 `updateTemplateData` 앞에서 조기 return 하고 액션 밖이라 `__g7ActionContext` 도 없으므로 **React 렌더가 0회** — memo 가 재계산되지 않아 `context.state` 가 입력 이전 스냅샷으로 고정된다. 여기에 폭 변경 리렌더가 `__g7PendingLocalState` 를 null 로 지우면(의존성 배열 없는 `useLayoutEffect`) base 가 stale A 로 떨어진다. diff --git a/CHANGELOG.md b/CHANGELOG.md index d352431d..4a5b85d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,11 +35,17 @@ - 확장 문서에서 제품을 가리키는 이름이 「그누보드7」로 통일되었습니다. 종전에는 같은 문서 안에서도 약칭과 정식 명칭이 섞여, 확장만 내려받은 사람에게 별개 제품처럼 보였습니다. - 번들 템플릿의 컴포넌트·핸들러·레이아웃 상세 문서와 확장이 사용하는 활동 로그 항목 목록이 각 확장의 문서로 옮겨졌습니다. 확장이 기능을 늘릴 때 코어 문서를 함께 고쳐야 하던 의존이 사라졌으며, 코어 문서에는 총계와 각 확장 문서로의 링크만 남습니다. +### Security + +- 주소에 마침표처럼 보이는 특수문자(전각·표의문자 마침표 등)를 섞으면 서버가 내부 주소로 요청을 보내도록 유도할 수 있던 문제를 수정했습니다. 검사할 때와 실제로 연결할 때 주소를 읽는 방식이 달라 생긴 문제로, 이제 두 시점이 같은 방식으로 주소를 해석합니다. 스케줄의 URL 호출, 주소로 언어팩 설치, 외부 배송비 계산 API 등 서버가 대신 외부로 요청을 보내는 모든 지점이 함께 보호됩니다. 정상적인 국제화 도메인(한글·일본어 도메인 등)은 그대로 사용할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2010) +- 2단계 인증을 켠 상태에서 계정 잠금을 우회할 수 있던 문제를 수정했습니다. 잠기기 전에 받아 둔 인증 단계를 잠긴 뒤에 마치면 로그인이 되고 잠금까지 풀렸습니다. 이제 인증번호 확인 단계에서도 잠금 여부를 다시 확인하며, 잠긴 계정은 로그인 화면과 동일한 안내를 받습니다. 잠긴 계정은 기존 로그인 상태로도 인증 기간을 연장할 수 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2011) + ### Fixed - 실제 화면 동작에 쓰이는 라이브러리(axios·laravel-echo·pusher-js)가 개발용으로 분류돼 있어 보안 점검에서 빠지던 문제를 수정했습니다. 이제 점검 대상에 포함되며, 함께 확인된 axios 취약점도 1.20.0 으로 올려 해소했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.) - 글을 쓰다 브라우저 창 크기가 바뀌면 저장 시 본문이 사라지던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 창 크기를 조금만 바꿔도(20픽셀 이내) 발생했으므로, 휴대폰에서 주소창이 숨겨지거나 키보드가 올라오거나 화면을 돌리는 것도 같은 상황입니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다. (#130 @jiwonpapa 님께서 제보해주셨습니다.) - 창 크기가 바뀐 뒤 본문을 고치고 제목 등 다른 입력칸을 건드리면, 저장 시 본문이 고치기 전 내용으로 되돌아가던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 편집기에는 고친 내용이 그대로 보였기 때문에 저장 후 다시 열어보기 전까지는 알 수 없었습니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다. +- 상품 상세에서 옵션을 고른 뒤 「바로 구매」·「장바구니 담기」가 동작하지 않던 문제를 수정했습니다. 화면에는 고른 옵션이 목록에 담긴 것으로 보이는데 실제 요청에는 아무 옵션도 실리지 않아, 「바로 구매」는 오류로 끝나고 「장바구니 담기」는 「옵션을 선택해주세요」 안내만 반복됐습니다. 옵션 조합을 두 번 담으면 먼저 담은 조합이 사라지는 것도 같은 원인입니다. 상품 추가옵션(각인·포장 등) 선택도 함께 정상화됐습니다. - 서버측 라이브러리 guzzle·commonmark 의 알려진 취약점 12건을 해소했습니다. 결제·본인인증·알림 발송처럼 외부와 통신하는 경로가 이 라이브러리를 사용합니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.) - 초기 화면 파일을 명령줄과 웹이 번갈아 만들 때, 나중에 생기는 하위 폴더가 한쪽 계정 전용으로 남아 다른 쪽이 쓰지 못하던 문제를 수정했습니다. 게시 폴더가 그룹 권한을 하위 폴더에 물려주도록 정리합니다(Linux·macOS). - 확장 설치가 의존성·버전 검사에서 실패해도 복사된 파일이 남아, 목록에도 보이지 않는 디렉토리가 쌓이던 문제를 수정했습니다. 실패한 설치는 이번에 만든 파일을 되돌립니다(이미 설치돼 있던 확장을 다시 설치하다 실패한 경우에는 기존 파일을 건드리지 않습니다). diff --git a/app/Http/Controllers/Api/Admin/AuthController.php b/app/Http/Controllers/Api/Admin/AuthController.php index 439f274d..f6bdc66a 100644 --- a/app/Http/Controllers/Api/Admin/AuthController.php +++ b/app/Http/Controllers/Api/Admin/AuthController.php @@ -110,6 +110,19 @@ class AuthController extends AdminBaseController } return $this->success('common.success', $data); + } catch (AccountLockedException $e) { + // 재발급도 세션을 여는 지점이다 — 사용자 경로와 같은 423 계약을 따른다. + // 이 catch 가 없으면 잠긴 계정의 재발급 시도가 500 으로 새어 나간다. + return $this->error( + $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked', + 423, + [ + 'locked_until' => $e->lockedUntil?->toIso8601String(), + 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60, + 'permanent' => $e->isPermanent(), + ], + ['minutes' => $e->remainingMinutes] + ); } catch (ValidationException $e) { return $this->unauthorized('auth.unauthenticated'); } diff --git a/app/Http/Controllers/Api/Auth/AuthController.php b/app/Http/Controllers/Api/Auth/AuthController.php index d481397e..d66a9007 100644 --- a/app/Http/Controllers/Api/Auth/AuthController.php +++ b/app/Http/Controllers/Api/Auth/AuthController.php @@ -60,17 +60,7 @@ class AuthController extends AuthBaseController return $this->success('auth.login_success', $data); } catch (AccountLockedException $e) { - // 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출. - return $this->error( - $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked', - 423, - [ - 'locked_until' => $e->lockedUntil?->toIso8601String(), - 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60, - 'permanent' => $e->isPermanent(), - ], - ['minutes' => $e->remainingMinutes] - ); + return $this->lockedResponse($e); } catch (ValidationException $e) { return $this->unauthorized('auth.login_failed'); } @@ -98,11 +88,38 @@ class AuthController extends AuthBaseController $data['user'] = new UserResource($data['user']); return $this->success('auth.login_success', $data); + } catch (AccountLockedException $e) { + // 세션을 여는 지점이므로 `login` 과 같은 423 계약을 따른다 — 화면은 두 경로를 + // 구분하지 않으므로 한쪽만 다른 모양이면 잠금 안내가 깨진다. + return $this->lockedResponse($e); } catch (ValidationException $e) { return $this->unauthorized('auth.two_factor_failed'); } } + /** + * 계정 잠금 응답(423)을 구성합니다. + * + * 세션을 발급하는 모든 엔드포인트가 같은 페이로드를 돌려주도록 단일 지점에서 만든다. + * + * @param AccountLockedException $e 잠금 예외 + * @return JsonResponse 423 응답 + */ + private function lockedResponse(AccountLockedException $e): JsonResponse + { + // 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출. + return $this->error( + $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked', + 423, + [ + 'locked_until' => $e->lockedUntil?->toIso8601String(), + 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60, + 'permanent' => $e->isPermanent(), + ], + ['minutes' => $e->remainingMinutes] + ); + } + /** * 새로운 사용자를 등록시킵니다. * @@ -178,7 +195,11 @@ class AuthController extends AuthBaseController */ public function refresh(AuthenticatedRequest $request): JsonResponse { - $data = $this->authService->refreshToken($request->user()); + try { + $data = $this->authService->refreshToken($request->user()); + } catch (AccountLockedException $e) { + return $this->lockedResponse($e); + } // 사용자 정보는 Resource로, 토큰은 그대로 if (isset($data['user'])) { diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php index 7dd137dc..dea93863 100644 --- a/app/Services/AuthService.php +++ b/app/Services/AuthService.php @@ -100,20 +100,7 @@ class AuthService // 사전 잠금 체크 — 잠긴 계정은 Auth::attempt 자체를 시도하지 않는다. // (실패 카운트가 0 으로 리셋된 잠금 상태에서 Failed 이벤트가 다시 // 카운트를 올려 재잠금 시각을 갱신하는 부작용 방지) - if ((bool) g7_core_settings('security.login_attempt_enabled', true)) { - $candidate = $this->userRepository->findByEmail($email); - if ($candidate !== null && $this->userRepository->isLocked($candidate)) { - // 영구 잠금은 해제 시각이 없다 — diffInSeconds(null) 로 폭발하지 않도록 분기. - $remaining = $candidate->locked_until === null - ? null - : max(1, (int) ceil(now()->diffInSeconds($candidate->locked_until, false) / 60)); - - throw new AccountLockedException( - lockedUntil: $candidate->locked_until, - remainingMinutes: $remaining, - ); - } - } + $this->assertNotLocked($this->userRepository->findByEmail($email)); if (! Auth::attempt(['email' => $email, 'password' => $password])) { // 실패 카운트 증가/잠금 처리는 HandleFailedLoginListener 에서 담당 @@ -158,6 +145,40 @@ class AuthService return $this->issueLoginSession($user, $email); } + /** + * 계정이 잠겨 있으면 예외를 던집니다. + * + * 세션(토큰)을 발급하는 지점은 전부 이 검사를 거쳐야 합니다. 2단계 인증이 켜져 있으면 + * 비밀번호 확인(`login`)은 challenge 만 돌려주고 실제 세션은 `completeTwoFactor()` 가 + * 발급하므로, 한쪽에만 검사가 있으면 잠기기 전에 받아 둔 challenge 를 잠긴 뒤 완료하는 + * 것만으로 잠금이 통째로 우회됩니다. 그 뒤 로그인 완료 훅이 실패 횟수·잠금 시각까지 + * 초기화해 흔적도 남지 않습니다. + * + * @param User|null $user 검사 대상 사용자 (없으면 검사 대상 아님) + * + * @throws AccountLockedException 계정이 잠겨 있을 때 + */ + private function assertNotLocked(?User $user): void + { + if (! (bool) g7_core_settings('security.login_attempt_enabled', true)) { + return; + } + + if ($user === null || ! $this->userRepository->isLocked($user)) { + return; + } + + // 영구 잠금은 해제 시각이 없다 — diffInSeconds(null) 로 폭발하지 않도록 분기. + $remaining = $user->locked_until === null + ? null + : max(1, (int) ceil(now()->diffInSeconds($user->locked_until, false) / 60)); + + throw new AccountLockedException( + lockedUntil: $user->locked_until, + remainingMinutes: $remaining, + ); + } + /** * 이 사용자에게 2단계 인증을 요구해야 하는지 판정합니다. * @@ -268,6 +289,10 @@ class AuthService ]); } + // 세션을 여는 것은 이 지점이다 — challenge 발급 이후에 잠겼을 수 있으므로 재검사한다. + // Auth::login() 앞에 두어야 로그인 완료 훅이 잠금 필드를 초기화하지 못한다. + $this->assertNotLocked($user); + Auth::login($user); return $this->issueLoginSession($user, (string) $user->email); @@ -446,9 +471,15 @@ class AuthService * * @param User $user 토큰을 갱신할 사용자 * @return array 새로운 토큰 정보 + * + * @throws AccountLockedException 계정이 잠겨 있을 때 */ public function refreshToken(User $user): array { + // 재발급도 세션을 여는 지점이다. 유효한 기존 세션이 전제라 신규 로그인 우회는 + // 아니지만, 관리자가 계정을 잠근 뒤에도 그 세션이 무기한 연장되면 잠금이 실효를 잃는다. + $this->assertNotLocked($user); + // 현재 토큰 삭제 (다른 디바이스는 유지) $currentToken = $user->currentAccessToken(); diff --git a/app/Support/OutboundUrlValidator.php b/app/Support/OutboundUrlValidator.php index 04fdc40a..aa393c2c 100644 --- a/app/Support/OutboundUrlValidator.php +++ b/app/Support/OutboundUrlValidator.php @@ -111,6 +111,75 @@ class OutboundUrlValidator return self::extractSafeHost($url, $options + ['allowPort' => true]) !== null; } + /** + * host 문자열을 실제 연결 계층과 같은 규칙으로 정규화한다. + * + * 이 메서드가 정규화의 SSoT 다. 검증기 밖에서 host 를 대조하는 소비자(결제 콜백 + * URL 의 도메인 접미사 확인 등)도 이 메서드를 거쳐야 판정이 갈리지 않는다. + * + * 정규화 내용: + * - 점(.) 동등 유니코드 문자(U+3002·U+FF0E·U+FF61)를 ASCII 점으로 치환. + * 검증기가 ASCII 점만 구분자로 보면 `localhost。` 는 단일 라벨(공개 도메인)로 + * 읽히지만 libcurl/libidn2 는 UTS#46 정규화로 `localhost` 에 연결한다. + * - IDNA/UTS#46 A-label(punycode) 변환 — 유니코드 표기와 ASCII 표기를 한 형태로 모은다. + * - 소문자화 및 완전한 DNS 이름의 후행 점 제거. + * + * @param string $host 정규화할 host 문자열 (IPv6 는 대괄호 없이) + * @return string|null 정규화된 host, 정규화할 수 없으면 null + */ + public static function normalizeHost(string $host): ?string + { + $host = strtolower(trim($host)); + + if ($host === '') { + return null; + } + + // 점 동등 문자 사전 치환 — idn_to_ascii 는 구현에 따라 이들을 남길 수 있으므로 + // 라벨 분리 자체를 여기서 확정한다. + $host = strtr($host, [ + "\u{3002}" => '.', // IDEOGRAPHIC FULL STOP + "\u{FF0E}" => '.', // FULLWIDTH FULL STOP + "\u{FF61}" => '.', // HALFWIDTH IDEOGRAPHIC FULL STOP + ]); + + if (function_exists('idn_to_ascii')) { + $ascii = idn_to_ascii($host, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46); + + if (is_string($ascii) && $ascii !== '') { + $host = $ascii; + } elseif (preg_match('/[^\x20-\x7E]/', $host) === 1) { + // 변환에 실패했는데 비-ASCII 가 남아 있으면 연결 계층이 어떤 host 로 + // 해석할지 알 수 없다 — 판정 불가는 거부로 처리한다. + return null; + } + } elseif (preg_match('/[^\x20-\x7E]/', $host) === 1) { + // polyfill 부재 환경 fail-safe: 비-ASCII host 는 판정 불가로 보고 거부. + return null; + } + + $host = strtolower($host); + + // 완전한 DNS 이름 표기(`example.com.`)의 후행 점 제거 — 남겨 두면 최상위 라벨이 + // 빈 문자열이 되어 정상 도메인이 차단되고, `localhost.` 가 내부 이름 대조를 빠져나간다. + while (str_ends_with($host, '.')) { + $host = substr($host, 0, -1); + } + + // 정규화 결과가 host 로 성립하는지 확인한다. UTS#46 은 전각 문자를 ASCII 로 + // 매핑하므로 `127.0.0.1/.example.com`(U+FF0F) 은 `127.0.0.1/.example.com` 이 된다. + // parse_url 은 전각 문자를 구분자로 보지 않아 이 전체를 host 로 넘기지만, 연결 + // 계층은 정규화 후 첫 구분자 앞(`127.0.0.1`)까지만 host 로 읽는다. 즉 접미사· + // 화이트리스트 대조는 뒤쪽 도메인으로 통과하는데 실제 접속은 앞쪽 주소로 간다. + // punycode(A-label)와 IP 리터럴은 항상 letter/digit/hyphen/dot 뿐이므로, 그 밖의 + // 문자가 남았다면 어느 host 로 해석될지 알 수 없다 — 판정 불가는 거부로 처리한다. + if (preg_match('/^[a-z0-9._-]+$/', $host) !== 1) { + return null; + } + + return $host === '' ? null : $host; + } + /** * host 문자열(URL 이 아닌 host 단독)이 공개 인터넷 주소인지 판정한다. * @@ -126,6 +195,13 @@ class OutboundUrlValidator $host = substr($host, 1, -1); } + // 직접 호출자(URL 을 거치지 않는 경로)도 같은 정규화를 받아야 한다. + // 단 IP 리터럴은 IDNA 라벨 구조가 없어 정규화 대상이 아니다 — 특히 IPv6 의 ':' 는 + // 정규화의 호스트명 문자 집합 검사에 걸려 공개 IPv6 까지 차단된다. + if (filter_var($host, FILTER_VALIDATE_IP) === false) { + $host = self::normalizeHost($host) ?? ''; + } + if ($host === '' || in_array($host, self::INTERNAL_HOST_NAMES, true)) { return false; } @@ -208,6 +284,13 @@ class OutboundUrlValidator $host = strtolower(trim($parts['host'])); - return $host === '' ? null : $host; + // IPv6 리터럴은 대괄호째 유지한다 — 라벨 구조가 없어 IDNA 정규화 대상이 아니다. + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + return $host === '[]' ? null : $host; + } + + // 실제 연결 계층(libcurl/libidn2)과 같은 규칙으로 정규화한 뒤 상위 판정에 넘긴다. + // 정규화 없이 넘기면 `localhost。` 처럼 검증 시점과 연결 시점의 host 가 달라진다. + return self::normalizeHost($host); } } diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index b2d1a87e..76e9565f 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -248,7 +248,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 14 · **엔드포인트 수**: 416 +- **확장 수**: 14 · **엔드포인트 수**: 428 | 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | | --- | --- | --- | --- | @@ -256,10 +256,10 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `sirsoft-board` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 | | `sirsoft-ecommerce` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 239 | | `sirsoft-page` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 | -| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | +| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 | | `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | | `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | -| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 21 | | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 | | `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 | | `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 | diff --git a/docs/backend/api/auth.md b/docs/backend/api/auth.md index 69fca6b7..f945840b 100644 --- a/docs/backend/api/auth.md +++ b/docs/backend/api/auth.md @@ -153,6 +153,7 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 423 | Locked | 계정이 잠긴 경우. 응답 형태는 로그인 엔드포인트의 423 과 동일하다 (`auth.account_locked` / `auth.account_locked_permanently` — `errors.locked_until`, `errors.retry_after_seconds`, `errors.permanent`) | @@ -160,6 +161,8 @@ HTTP/1.1 200 현재 관리자 토큰을 새 Sanctum 토큰으로 교체한다. `AuthService::refreshToken()` 이 기존 토큰을 폐기하고 새 토큰을 발급하며, `data` 에는 새 `token` 과 `user`(UserResource) 가 담긴다. 만료 임박 토큰을 재발급하는 용도로, 세션 만료로 재인증이 필요한 경우(토큰 무효)에는 `401 auth.unauthenticated` 를 반환한다. +**재발급도 잠금 검사를 거친다.** 유효한 기존 세션이 전제이므로 신규 로그인 우회는 아니지만, 관리자가 계정을 잠근 뒤에도 그 세션이 무기한 연장되면 잠금이 실효를 잃는다. 잠긴 계정의 재발급 요청은 `423` 으로 차단되며 기존 토큰도 폐기되지 않는다. + ### GET /api/admin/auth/user @@ -586,6 +589,7 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthorized | 코드가 틀렸거나(`auth.two_factor_failed`), challenge 의 `purpose` 가 `login` 이 아니거나, 확인된 사용자가 없거나 `active` 상태가 아닌 경우. **세 사유를 같은 응답으로 뭉뚱그린다** — 구분해 내보내면 challenge 유효성 탐색에 쓰인다 | | 422 | Unprocessable Entity | `challenge_id`/`code` 형식 위반 | +| 423 | Locked | 로그인 실패 누적으로 계정이 잠긴 경우. 응답 형태는 `POST /api/auth/login` 의 423 과 동일하다 (`auth.account_locked` / 무기한이면 `auth.account_locked_permanently` — `errors.locked_until`, `errors.retry_after_seconds`, `errors.permanent`) | | 429 | Too Many Requests | `throttle:auth-login` 초과 (로그인과 같은 제한을 공유하므로 코드 대입 시도도 함께 억제된다) | @@ -596,6 +600,8 @@ HTTP/1.1 200 challenge 의 `purpose` 가 `login` 인지 먼저 대조한다 — 대조하지 않으면 회원가입·비밀번호 재설정 등 다른 흐름에서 발급된 challenge 로 로그인할 수 있다. 코드 확인에 성공하기 전에는 어떤 경우에도 토큰이 발급되지 않는다. +**계정 잠금은 이 단계에서 다시 검사한다.** 세션을 여는 것은 비밀번호 단계가 아니라 이 엔드포인트이므로, challenge 를 받은 뒤 잠긴 계정은 여기서 `423` 으로 차단된다. 잠기기 전에 발급받은 challenge 를 잠긴 뒤에 완료하는 것만으로 잠금을 우회할 수 없다. 차단은 로그인 완료 훅(`core.auth.after_login`)보다 앞서므로 실패 횟수·잠금 해제 시각도 초기화되지 않는다. + ### POST /api/auth/logout @@ -1191,6 +1197,7 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.auth.refresh`)이 없는 경우 | +| 423 | Locked | 계정이 잠긴 경우. 응답 형태는 로그인 엔드포인트의 423 과 동일하다 (`auth.account_locked` / `auth.account_locked_permanently` — `errors.locked_until`, `errors.retry_after_seconds`, `errors.permanent`). 잠긴 계정은 기존 세션으로도 토큰을 연장할 수 없으며, 차단 시 기존 토큰도 폐기되지 않는다 | diff --git a/docs/frontend/layout-json.md b/docs/frontend/layout-json.md index dc7762fb..489d37fe 100644 --- a/docs/frontend/layout-json.md +++ b/docs/frontend/layout-json.md @@ -87,6 +87,25 @@ | `meta.seo` | object | ❌ | SEO 페이지 생성기 설정 (아래 참조) | | `components` | array | ✅ | 컴포넌트 배열 | +### 한 객체에 같은 키를 두 번 쓰지 않는다 + +JSON 은 중복 키를 문법 오류로 보지 않는다. 브라우저(`JSON.parse`)도 서버 등록(`json_decode`)도 +**뒤에 온 값이 앞의 값을 덮는다.** 그래서 앞에 쓴 선언은 예외도 경고도 없이 사라지는데, +파일에는 그대로 남아 있으므로 코드를 읽는 사람에게는 반영된 것처럼 보인다. + +가장 자주 걸리는 자리는 `props` 다 — 노드가 `children` **뒤**에 `"props": {}` 를 이미 갖고 있는데 +작성자가 노드 앞머리에 `"props": { ... }` 를 새로 적는 경우다. 노드가 길면 앞머리와 꼬리가 +한 화면에 들어오지 않아 눈으로는 발견되지 않고, 그 속성만 화면에 영영 나타나지 않는다. + +| ❌ 금지 | ✅ 올바른 사용 | +|--------|---------------| +| 한 노드에 `props`(또는 `comment`·`actions` 등)를 두 번 선언 | 하나로 합친다 — 값을 추가할 때는 그 노드에 **이미 있는** 키를 찾아 거기에 넣는다 | +| 노드 앞머리에 키를 추가하기 전에 꼬리를 확인하지 않음 | 노드 전체에서 그 키의 존재를 먼저 확인한다 | + +의도적으로 재선언해야 하는 예외는 그 객체의 `comment` 에 +`audit:allow layout-json-duplicate-object-key <사유>` 를 남긴다 (JSON 은 주석을 담을 수 없으므로 +표식 자리가 `comment` 값이다). 정적 검사가 차단한다. + ### layout_name 네이밍 규칙 ```text diff --git a/docs/frontend/state-management.md b/docs/frontend/state-management.md index 0101fc9e..11aad204 100644 --- a/docs/frontend/state-management.md +++ b/docs/frontend/state-management.md @@ -171,6 +171,9 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서 ❌ `__g7AutoBindingPaths` 레지스트리를 건드리지 않고 자동바인딩 변형 구현 ❌ setLocal의 `render:false` 자동 승격 분기를 임의로 제거하거나 조건 완화 ❌ 자동바인딩의 pending 스냅샷을 저장소 A 값만으로 구성 (B 전용 값이 조용히 사라진다) +❌ 저장소 A 에만 쓰는 `_local` 쓰기 경로 추가 (`context.setState(payload)` 단독 호출) +❌ 키가 **존재하는** 것만 확인하고 그 값이 **최신인지** 보지 않기 (존재 ≠ 신선도) +❌ 하네스에서 `globalState._local` 을 손으로 채워 발산 상황을 위조 (실 writer 를 거치지 않으면 결함이 시험에 등장하지 않는다) ❌ 구독 기반 선택적 리렌더 재시도 (과거에 도입 후 롤백된 실패 경로 — 반드시 검토 후 논의) ``` @@ -182,6 +185,10 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서 ✅ pending 에 쓰는 값이 렌더러가 만드는 `_local` 과 같은 합성 순서인지 확인 ✅ DynamicRenderer의 레지스트리 useEffect 조건 변경 시 iteration/Strict Mode 이중 마운트 영향 검토 ✅ SPA 네비게이션 시 레지스트리 재초기화 (new Map()) 유지 +✅ 미러는 그 쓰기를 **지배하는 분기 안**에 둔다 (형제 분기의 미러는 이 분기를 면죄하지 않는다) +✅ 계약 테스트는 경로마다 A→B / B→A **양방향 쌍**으로 둔다 (한 방향만 두면 반대 방향 회귀가 초록으로 통과한다) +✅ 하네스는 실제 writer(`G7Core.state.setLocal` · 자동바인딩 · `ActionDispatcher`)를 거친다 +✅ `describe.skip` 은 커버리지가 아니다 — 꺼진 시험은 한 번도 돌지 않는다 ✅ 수정 후 이중 저장소 동기화 관련 회귀 테스트 전수 통과 확인 ``` diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 5e0cb145..9bff2cc6 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.1.4] - 2026-09-02 + +### Added + +- 주문 설정의 「결제 미완료 주문 만료 기준(분)」 입력과 그 검증 문구의 일본어 번역을 추가했습니다. + ## [1.1.3] - 2026-08-24 ### Fixed diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php index 741512fa..cae44ee0 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php @@ -1281,6 +1281,7 @@ return [ 'order_settings.bank_accounts.*.is_default' => 'デフォルト口座', 'order_settings.auto_cancel_expired' => '未決済自動キャンセル', 'order_settings.auto_cancel_days' => '自動キャンセル期限(日)', + 'order_settings.pending_order_expire_minutes' => '決済未完了注文の期限(分)', 'order_settings.cart_expiry_days' => 'カート保管期間(日)', 'order_settings.default_pg_provider' => 'デフォルトPG会社', 'order_settings.payment_methods.*.pg_provider' => 'PG会社', @@ -1609,6 +1610,12 @@ return [ 'min' => '自動キャンセルの期限は1日以上である必要があります。', 'max' => '自動キャンセルの期限は最大30日まで設定可能です。', ], + 'pending_order_expire_minutes' => [ + 'required' => '決済未完了注文の期限を入力してください。', + 'integer' => '決済未完了注文の期限は整数である必要があります。', + 'min' => '決済未完了注文の期限は0分以上である必要があります。(0 は整理しない)', + 'max' => '決済未完了注文の期限は最大20160分(14日)まで設定可能です。', + ], 'cart_expiry_days' => [ 'integer' => 'カートの保管期間は整数である必要があります。', 'min' => 'カートの保管期間は1日以上である必要があります。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json index 60dd5aab..c90cba41 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json @@ -215,6 +215,9 @@ "toggle_hint": "無効化すると入金待機注文は自動キャンセルされません。", "days_prefix": "入金待機ステータスの注文は注文日を含めて", "days_suffix": "日後に自動キャンセルされます。", + "pending_minutes_prefix": "決済画面まで進んだものの決済が完了しなかった注文は", + "pending_minutes_suffix": "分後に自動キャンセルされます。", + "pending_minutes_hint": "0 にするとこの区分は自動キャンセルしません。最大 20160 分(14日)。", "vbank_due_days_label": "仮想口座入金期限:", "vbank_due_days_suffix": "日", "dbank_due_days_label": "無通帳振込入金期限:", diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json index 44b5c565..0fe552b6 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)", "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)" }, - "version": "1.1.3", + "version": "1.1.4", "license": "MIT", "scope": "module", "target_identifier": "sirsoft-ecommerce", diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index c8bace6e..897834f2 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -6,11 +6,16 @@ ## [1.2.1] - 2026-08-28 +### Changed + +- 입금 기한 만료 주문 자동취소가 카드 등 결제창 결제까지 대상에 포함합니다. 종전에는 무통장입금·가상계좌만 정리되어, 결제창까지 갔으나 결제가 끝나지 않은 주문은 정리하는 주체가 없어 계속 남았습니다. 이제 주문 후 24시간이 지나면 함께 취소되며 미리 차감된 마일리지도 돌아옵니다. 결제가 이미 완료된 주문과 입금 대기 중인 가상계좌 주문은 대상이 아니며, 주문설정의 「만료 주문 자동취소」를 끄면 종전처럼 동작합니다. + ### Added - 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. - 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. - 문서의 제품 표기를 「그누보드7」로 통일했습니다. +- 환경설정 > 주문 설정 > 주문 자동취소 에 「결제 미완료 주문 만료 기준(분)」 입력이 추가되었습니다. 결제창까지 갔으나 결제가 끝나지 않은 주문을 몇 분 뒤에 정리할지 정하며, 0 으로 두면 그 부류는 정리하지 않습니다(기본 1440분 = 24시간). 종전에는 값만 있고 화면에서 조작할 수 없었습니다. ### Fixed diff --git a/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php b/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php index 3fdd51cc..65199dea 100644 --- a/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php +++ b/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php @@ -85,6 +85,10 @@ return [ // 주문 'auto_cancel_days_min' => 1, 'auto_cancel_days_max' => 30, + // 결제창까지 갔으나 결제가 성립하지 않은 주문의 만료 기준(분). + // 0 은 "그 부류는 정리하지 않음" 을 뜻한다 — 운영자가 끌 수 있는 여지를 남긴다. + 'pending_order_expire_minutes_min' => 0, + 'pending_order_expire_minutes_max' => 20160, 'cart_expiry_days_min' => 1, 'cart_expiry_days_max' => 365, diff --git a/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json b/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json index 5b6da90d..6556afe1 100644 --- a/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json +++ b/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json @@ -241,6 +241,7 @@ ], "auto_cancel_expired": true, "auto_cancel_days": 3, + "pending_order_expire_minutes": 1440, "cart_expiry_days": 30, "stock_restore_on_cancel": true, "cancellable_statuses": ["payment_complete"], diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md index 940e9f07..528c8cc5 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md @@ -480,6 +480,7 @@ HTTP/1.1 200 | order_settings.bank_accounts | body | array | 아니오 | — | 무통장 입금 계좌 목록. 항목별 `bank_code`·`account_number`·`account_holder`(모두 필수)·`is_active`·`is_default`. 계좌가 있으면 최소 1건은 사용+기본 상태여야 함 | | order_settings.auto_cancel_expired | body | boolean | 아니오 | — | 입금대기 상태 주문의 자동취소 사용 여부 | | order_settings.auto_cancel_days | body | integer | 아니오 | min 0, max 30 | 자동취소 기한(일). 주문일 포함 이 일수 경과 시 입금대기 주문을 자동 취소 | +| order_settings.pending_order_expire_minutes | body | integer | 아니오 | min 0, max 20160 | 결제 미완료 주문 만료 기준(분). 결제창까지 갔으나 결제가 성립하지 않은 주문을 이 시간 경과 후 자동 취소. `0` 이면 그 부류를 정리하지 않음 | | order_settings.cart_expiry_days | body | integer | 아니오 | min 1, max 365 | 장바구니 보관기간(일). 경과 시 담긴 상품 자동 삭제 | | order_settings.stock_restore_on_cancel | body | boolean | 아니오 | — | 주문 취소 시 차감된 재고 자동 복구 여부 (반품/교환에도 적용) | | order_settings.confirmable_statuses | body | array | 아니오 | — | 사용자가 구매확정할 수 있는 주문 옵션 상태 목록 (`payment_complete`, `shipping_hold`, `preparing`, `shipping_ready`, `shipping`, `delivered` 중 선택) | @@ -599,6 +600,7 @@ Content-Type: application/json ], "order_settings.auto_cancel_expired": true, "order_settings.auto_cancel_days": 1, + "order_settings.pending_order_expire_minutes": 1440, "order_settings.cart_expiry_days": 1, "order_settings.stock_restore_on_cancel": true, "order_settings.confirmable_statuses": [ diff --git a/modules/_bundled/sirsoft-ecommerce/docs/settings.md b/modules/_bundled/sirsoft-ecommerce/docs/settings.md index 11fde25d..39353242 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/settings.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/settings.md @@ -35,6 +35,21 @@ _`getSettingsSchema()` 선언이 없습니다._ 등록한 카탈로그의 병합**이라, 플러그인을 삭제·비활성화하면 저장값은 남아 있는데 카탈로그에서 사라지는 고아 항목이 생깁니다. 공개 응답은 고아 항목을 걸러 내보내고 관리자 응답은 그대로 노출하는 것이 규칙입니다 — 운영자는 그것을 보고 지워야 하기 때문입니다. + +`order_settings.pending_order_expire_minutes` 는 결제창까지 갔으나 결제가 성립하지 않은 +주문(`PENDING_ORDER`)을 만료 자동취소 대상에 넣는 기준입니다(기본 1440분 = 24시간). 0 이면 그 +부류를 정리하지 않습니다. + +이 값은 **환경설정 > 주문 설정 > 주문 자동취소** 카드에 있습니다. 형제 값 +`auto_cancel_days` 와 달리 `auto_cancel_expired` 토글 **하위가 아닙니다** — 정리 스케줄러가 그 +토글과 무관하게 이 값을 읽기 때문에, 토글을 끈 상태에서 이 입력만 사라지면 화면이 실제 동작을 +설명하지 못합니다. 입력 경계(0 ~ 20160분)는 `config/ecommerce.php` 의 `limits` 가 단일 출처이며, +화면은 설정 응답의 `_meta.limits` 로 같은 값을 받습니다. + +이 부류를 정리 대상에 넣은 이유는 **정리 주체가 아예 없었기** 때문입니다. 기존 자동취소는 입금 +기한(`vbank_due_at`·`deposit_due_at`)이 있는 결제수단만 훑고, 임시주문 정리는 다른 테이블을 봅니다. +브라우저 리턴 콜백이 주문 상태를 바꾸지 않게 된 뒤로는 승인 거절분도 여기에 머무르므로, 이 정리가 +선차감 마일리지 복원의 최종 안전망입니다. ## 권한 diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json index be8d1dda..d424c7d4 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json @@ -25,6 +25,7 @@ "name": "Button", "props": { "className": "flex items-center gap-1.5 px-2.5 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg cursor-pointer transition-colors", + "data-testid": "currency-switcher", "aria-haspopup": "listbox", "aria-expanded": "{{_local.showCurrencyDropdown ?? false}}", "aria-label": "$t:sirsoft-ecommerce.common.currency_label" @@ -151,6 +152,7 @@ "name": "Button", "props": { "className": "w-full px-4 py-2.5 text-left text-sm flex items-center gap-3 cursor-pointer transition-colors {{_global.preferredCurrency === currency.code ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}}", + "data-testid": "currency-option-{{currency.code}}", "role": "option", "aria-selected": "{{_global.preferredCurrency === currency.code}}" }, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json index 6ce90069..06bd848d 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json @@ -213,7 +213,10 @@ "toggle_description": "Automatically cancel pending payment orders after the specified period.", "toggle_hint": "When disabled, pending payment orders will not be auto-cancelled.", "days_prefix": "Pending payment orders will be auto-cancelled", - "days_suffix": "days after the order date." + "days_suffix": "days after the order date.", + "pending_minutes_prefix": "Orders that reached the payment window but did not complete are cancelled after", + "pending_minutes_suffix": "minutes.", + "pending_minutes_hint": "Set 0 to leave this group alone. Maximum 20160 minutes (14 days)." }, "cart_expiry": { "title": "Cart Expiry", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json index 1f2a64f7..61b0b76b 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json @@ -213,7 +213,10 @@ "toggle_description": "입금대기 상태의 주문을 지정 기간 후 자동으로 취소합니다.", "toggle_hint": "비활성화하면 입금대기 주문이 자동 취소되지 않습니다.", "days_prefix": "입금대기 상태의 주문건은 주문일 포함", - "days_suffix": "일 이후 자동 취소됩니다." + "days_suffix": "일 이후 자동 취소됩니다.", + "pending_minutes_prefix": "결제창까지 갔으나 결제가 끝나지 않은 주문은", + "pending_minutes_suffix": "분 이후 자동 취소됩니다.", + "pending_minutes_hint": "0 으로 두면 이 부류는 자동 취소하지 않습니다. 최대 20160분(14일)." }, "cart_expiry": { "title": "장바구니 유효기간", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_form.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_form.json index e8107d1a..230dd052 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_form.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_form.json @@ -976,7 +976,8 @@ "props": { "type": "button", "disabled": "{{_local.isSaving || (route.itemCode && product?.data?.abilities?.can_update !== true)}}", - "className": "flex-center btn btn-primary gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed" + "className": "flex-center btn btn-primary gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed", + "data-testid": "product-save" }, "children": [ { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_form/_modal_additional_options_clear.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_form/_modal_additional_options_clear.json index ee0d0be1..98d19963 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_form/_modal_additional_options_clear.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_form/_modal_additional_options_clear.json @@ -46,7 +46,8 @@ "props": { "type": "button", "variant": "secondary", - "className": "btn btn-outline" + "className": "btn btn-outline", + "data-testid": "additional-clear-cancel" }, "text": "$t:sirsoft-ecommerce.common.cancel", "actions": [ @@ -62,7 +63,8 @@ "props": { "type": "button", "variant": "danger", - "className": "btn btn-danger" + "className": "btn btn-danger", + "data-testid": "additional-clear-confirm" }, "text": "$t:sirsoft-ecommerce.common.confirm", "actions": [ diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_form/_partial_product_options.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_form/_partial_product_options.json index 84070764..a28ec520 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_form/_partial_product_options.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_form/_partial_product_options.json @@ -1661,7 +1661,8 @@ "props": { "type": "button", "className": "btn-group-item {{(_local.form.additional_options ?? []).length > 0 ? 'active' : ''}} disabled:opacity-50 disabled:cursor-not-allowed", - "disabled": "{{route.itemCode && product?.data?.abilities?.can_update !== true}}" + "disabled": "{{route.itemCode && product?.data?.abilities?.can_update !== true}}", + "data-testid": "additional-option-use" }, "text": "$t:sirsoft-ecommerce.common.use", "actions": [ @@ -1678,7 +1679,8 @@ "props": { "type": "button", "className": "btn-group-item {{(_local.form.additional_options ?? []).length === 0 ? 'active' : ''}} disabled:opacity-50 disabled:cursor-not-allowed", - "disabled": "{{route.itemCode && product?.data?.abilities?.can_update !== true}}" + "disabled": "{{route.itemCode && product?.data?.abilities?.can_update !== true}}", + "data-testid": "additional-option-not-use" }, "text": "$t:sirsoft-ecommerce.common.not_use", "actions": [ @@ -1891,7 +1893,8 @@ "index_var": "addValIdx" }, "props": { - "className": "flex flex-col gap-2 sm:flex-row sm:items-start p-3 bg-white dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded" + "className": "flex flex-col gap-2 sm:flex-row sm:items-start p-3 bg-white dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded", + "data-testid": "additional-value-{{addIdx}}-{{addValIdx}}" }, "children": [ { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json index 93fac16e..9ce2b72f 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json @@ -662,6 +662,64 @@ "text": "{{_local.errors?.['order_settings.auto_cancel_days']?.[0] ?? ''}}" } ] + }, + { + "comment": "결제 미완료 주문 만료 기준(분) — 결제창까지 갔으나 결제가 성립하지 않은 주문을 정리하는 스케줄러가 읽는다. 자동취소 토글과 독립이므로 토글 하위에 두지 않는다.", + "id": "pending_order_expire_minutes_row", + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2 flex-wrap" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-body" + }, + "text": "$t:sirsoft-ecommerce.admin.settings.order_settings.auto_cancel.pending_minutes_prefix" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "number", + "name": "order_settings.pending_order_expire_minutes", + "value": "{{_local.form?.order_settings?.pending_order_expire_minutes ?? 1440}}", + "min": "{{_local?.form?._meta?.limits?.pending_order_expire_minutes_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.pending_order_expire_minutes_max ?? 20160}}", + "step": "1", + "className": "{{_local.errors?.['order_settings.pending_order_expire_minutes'] ? 'input input-error w-24' : 'input w-24'}}", + "disabled": "{{_computed.isReadOnly}}" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-body" + }, + "text": "$t:sirsoft-ecommerce.admin.settings.order_settings.auto_cancel.pending_minutes_suffix" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint w-full" + }, + "text": "$t:sirsoft-ecommerce.admin.settings.order_settings.auto_cancel.pending_minutes_hint" + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.errors?.['order_settings.pending_order_expire_minutes']}}", + "props": { + "className": "form-error-xs w-full mt-1" + }, + "text": "{{_local.errors?.['order_settings.pending_order_expire_minutes']?.[0] ?? ''}}" + } + ] } ], "props": { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Console/Commands/CancelPendingPaymentOrdersCommand.php b/modules/_bundled/sirsoft-ecommerce/src/Console/Commands/CancelPendingPaymentOrdersCommand.php index acb46793..a80270cb 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Console/Commands/CancelPendingPaymentOrdersCommand.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Console/Commands/CancelPendingPaymentOrdersCommand.php @@ -65,8 +65,19 @@ class CancelPendingPaymentOrdersCommand extends Command ); try { + // 결제창까지 갔으나 결제가 성립하지 않은 주문(PG 카드 등)의 만료 기준(분). + // 0 이하로 두면 그 부류는 정리하지 않는다 — 운영자가 끌 수 있는 여지를 남긴다. + $pendingOrderExpireMinutes = (int) module_setting( + 'sirsoft-ecommerce', + 'order_settings.pending_order_expire_minutes', + 1440 + ); + // 만료된 주문 조회 - $expiredOrders = $this->orderRepository->getExpiredPendingPaymentOrders($limit); + $expiredOrders = $this->orderRepository->getExpiredPendingPaymentOrders( + $limit, + $pendingOrderExpireMinutes > 0 ? $pendingOrderExpireMinutes : null, + ); if ($expiredOrders->isEmpty()) { $this->info('처리할 만료 주문이 없습니다.'); @@ -82,9 +93,13 @@ class CancelPendingPaymentOrdersCommand extends Command foreach ($expiredOrders as $order) { $paymentMethodEnum = $order->payment?->payment_method; $paymentMethodValue = $paymentMethodEnum?->value ?? 'unknown'; - $dueAt = $paymentMethodEnum === PaymentMethodEnum::DBANK - ? $order->payment?->deposit_due_at - : $order->payment?->vbank_due_at; + $dueAt = match (true) { + $paymentMethodEnum === PaymentMethodEnum::DBANK => $order->payment?->deposit_due_at, + $paymentMethodEnum === PaymentMethodEnum::VBANK => $order->payment?->vbank_due_at, + // 결제창까지 갔으나 성립하지 않은 주문은 입금 기한이 없다 — 주문 시각을 보여 + // 운영자가 어느 기준으로 정리되었는지 알 수 있게 한다. + default => $order->ordered_at, + }; $this->line("- 주문번호: {$order->order_number} ({$paymentMethodValue}, 기한: {$dueAt})"); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php index 99f8602e..f3ef24fd 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php @@ -286,6 +286,7 @@ class StoreEcommerceSettingsRequest extends FormRequest // sometimes 필수: rules() 는 탭 구분 없이 적용되므로 무조건 required 로 두면 // 이 키를 보내지 않는 다른 탭(마일리지 등) 저장이 통째로 막힌다. 키가 온 경우에만 필수. 'order_settings.auto_cancel_days' => ['sometimes', 'required', 'integer', 'min:'.config('sirsoft-ecommerce.limits.auto_cancel_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.auto_cancel_days_max', 30)], + 'order_settings.pending_order_expire_minutes' => ['sometimes', 'required', 'integer', 'min:'.config('sirsoft-ecommerce.limits.pending_order_expire_minutes_min', 0), 'max:'.config('sirsoft-ecommerce.limits.pending_order_expire_minutes_max', 20160)], 'order_settings.cart_expiry_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.cart_expiry_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.cart_expiry_days_max', 365)], 'order_settings.stock_restore_on_cancel' => ['nullable', 'boolean'], 'order_settings.confirmable_statuses' => ['nullable', 'array'], @@ -1213,6 +1214,10 @@ class StoreEcommerceSettingsRequest extends FormRequest 'order_settings.auto_cancel_days.integer' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.integer'), 'order_settings.auto_cancel_days.min' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.min'), 'order_settings.auto_cancel_days.max' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.max'), + 'order_settings.pending_order_expire_minutes.required' => __('sirsoft-ecommerce::validation.custom.order_settings.pending_order_expire_minutes.required'), + 'order_settings.pending_order_expire_minutes.integer' => __('sirsoft-ecommerce::validation.custom.order_settings.pending_order_expire_minutes.integer'), + 'order_settings.pending_order_expire_minutes.min' => __('sirsoft-ecommerce::validation.custom.order_settings.pending_order_expire_minutes.min'), + 'order_settings.pending_order_expire_minutes.max' => __('sirsoft-ecommerce::validation.custom.order_settings.pending_order_expire_minutes.max'), 'order_settings.cart_expiry_days.integer' => __('sirsoft-ecommerce::validation.custom.order_settings.cart_expiry_days.integer'), 'order_settings.cart_expiry_days.min' => __('sirsoft-ecommerce::validation.custom.order_settings.cart_expiry_days.min'), 'order_settings.cart_expiry_days.max' => __('sirsoft-ecommerce::validation.custom.order_settings.cart_expiry_days.max'), diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php index 1fd98aa9..9c0050e7 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php @@ -167,14 +167,20 @@ interface OrderRepositoryInterface public function hasOrderByUser(int $userId): bool; /** - * 입금 기한 만료된 결제대기 주문 조회 + * 기한이 지난 미결제 주문 조회 * - * vbank/dbank 결제의 입금 기한이 지난 주문들을 조회합니다. + * 두 부류를 함께 조회합니다. + * - vbank/dbank 결제의 입금 기한이 지난 결제대기 주문 + * - 결제창까지 갔으나 결제가 성립하지 않은 주문대기 주문 (PG 카드 등, 경과 시간 기준) + * + * 후자는 입금 기한이라는 개념이 없어 어떤 정리 주체도 없이 남던 부류다. + * `$pendingOrderExpireMinutes` 가 null 이거나 0 이하면 후자는 조회하지 않는다. * * @param int $limit 최대 조회 개수 - * @return Collection 입금 기한 만료된 결제대기 주문 컬렉션 + * @param int|null $pendingOrderExpireMinutes 주문대기 주문의 만료 기준(분) + * @return Collection 기한이 지난 미결제 주문 컬렉션 */ - public function getExpiredPendingPaymentOrders(int $limit = 100): Collection; + public function getExpiredPendingPaymentOrders(int $limit = 100, ?int $pendingOrderExpireMinutes = null): Collection; /** * ID 목록으로 주문을 조회하고 ID 키 맵으로 반환합니다 (bulk activity log lookup). diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php index 9fbc1526..f95f61b5 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php @@ -17,6 +17,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; +use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\ShippingStatusEnum; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderAddress; @@ -785,23 +786,47 @@ class OrderRepository implements OrderRepositoryInterface /** * {@inheritDoc} */ - public function getExpiredPendingPaymentOrders(int $limit = 100): Collection + public function getExpiredPendingPaymentOrders(int $limit = 100, ?int $pendingOrderExpireMinutes = null): Collection { return $this->model ->with(['payment', 'user']) - ->where('order_status', OrderStatusEnum::PENDING_PAYMENT->value) - ->whereHas('payment', function ($query) { - $query->where(function ($q) { - // vbank 가상계좌 입금 기한 만료 - $q->where('payment_method', PaymentMethodEnum::VBANK->value) - ->whereNotNull('vbank_due_at') - ->where('vbank_due_at', '<', now()); - })->orWhere(function ($q) { - // dbank 무통장입금(수동 입금확인) 입금 기한 만료 - $q->where('payment_method', PaymentMethodEnum::DBANK->value) - ->whereNotNull('deposit_due_at') - ->where('deposit_due_at', '<', now()); + ->where(function ($outer) use ($pendingOrderExpireMinutes) { + // 입금 기한이 있는 결제수단 — 기한 도과분 + $outer->where(function ($scope) { + $scope->where('order_status', OrderStatusEnum::PENDING_PAYMENT->value) + ->whereHas('payment', function ($query) { + $query->where(function ($q) { + // vbank 가상계좌 입금 기한 만료 + $q->where('payment_method', PaymentMethodEnum::VBANK->value) + ->whereNotNull('vbank_due_at') + ->where('vbank_due_at', '<', now()); + })->orWhere(function ($q) { + // dbank 무통장입금(수동 입금확인) 입금 기한 만료 + $q->where('payment_method', PaymentMethodEnum::DBANK->value) + ->whereNotNull('deposit_due_at') + ->where('deposit_due_at', '<', now()); + }); + }); }); + + // 결제창까지 갔지만 결제가 성립하지 않은 주문(PG 카드 등) — 입금 기한이라는 개념이 + // 없어 종전에는 어떤 정리 주체도 없이 무한히 남았다. 브라우저 리턴 콜백이 주문 + // 상태를 바꾸지 않게 되면서(위조 콜백으로 남의 주문을 취소시킬 수 있었던 통로 차단) + // 승인 거절분도 여기에 머무르므로, 주문 시각 기준 경과분을 정리 대상에 포함한다. + // 선차감 마일리지 복원도 이 정리에서 함께 이루어진다. + if ($pendingOrderExpireMinutes !== null && $pendingOrderExpireMinutes > 0) { + $outer->orWhere(function ($scope) use ($pendingOrderExpireMinutes) { + $scope->where('order_status', OrderStatusEnum::PENDING_ORDER->value) + ->where('ordered_at', '<', now()->subMinutes($pendingOrderExpireMinutes)) + // 승인 콜백과 경쟁해 이미 결제가 성립한 주문은 건드리지 않는다. + ->whereHas('payment', function ($query) { + $query->whereNotIn('payment_status', [ + PaymentStatusEnum::PAID->value, + PaymentStatusEnum::WAITING_DEPOSIT->value, + ]); + }); + }); + } }) ->orderBy('ordered_at', 'asc') ->limit($limit) diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php index 85571590..1480fb69 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php @@ -1451,6 +1451,7 @@ return [ 'order_settings.bank_accounts.*.is_default' => 'Default Account', 'order_settings.auto_cancel_expired' => 'Auto Cancel Unpaid Orders', 'order_settings.auto_cancel_days' => 'Auto Cancel Days', + 'order_settings.pending_order_expire_minutes' => 'Pending Payment Order Expiry (minutes)', 'order_settings.cart_expiry_days' => 'Cart Expiry Days', 'order_settings.default_pg_provider' => 'Default PG Provider', 'order_settings.payment_methods.*.pg_provider' => 'PG Provider', @@ -1781,6 +1782,12 @@ return [ 'min' => 'Auto cancel days must be at least 1.', 'max' => 'Auto cancel days cannot exceed 30.', ], + 'pending_order_expire_minutes' => [ + 'required' => 'Please enter the pending payment order expiry.', + 'integer' => 'Pending payment order expiry must be an integer.', + 'min' => 'Pending payment order expiry must be at least 0 (0 disables cleanup).', + 'max' => 'Pending payment order expiry cannot exceed 20160 minutes (14 days).', + ], 'cart_expiry_days' => [ 'integer' => 'Cart expiry days must be an integer.', 'min' => 'Cart expiry days must be at least 1.', diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php index e18042c8..95f62ccf 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php @@ -1451,6 +1451,7 @@ return [ 'order_settings.bank_accounts.*.is_default' => '기본 계좌', 'order_settings.auto_cancel_expired' => '미결제 자동취소', 'order_settings.auto_cancel_days' => '자동취소 기한(일)', + 'order_settings.pending_order_expire_minutes' => '결제 미완료 주문 만료 기준(분)', 'order_settings.cart_expiry_days' => '장바구니 보관기간(일)', 'order_settings.default_pg_provider' => '기본 PG사', 'order_settings.payment_methods.*.pg_provider' => 'PG사', @@ -1781,6 +1782,12 @@ return [ 'min' => '자동취소 기한은 1일 이상이어야 합니다.', 'max' => '자동취소 기한은 최대 30일까지 설정 가능합니다.', ], + 'pending_order_expire_minutes' => [ + 'required' => '결제 미완료 주문 만료 기준을 입력해주세요.', + 'integer' => '결제 미완료 주문 만료 기준은 정수여야 합니다.', + 'min' => '결제 미완료 주문 만료 기준은 0분 이상이어야 합니다. (0 은 정리하지 않음)', + 'max' => '결제 미완료 주문 만료 기준은 최대 20160분(14일)까지 설정 가능합니다.', + ], 'cart_expiry_days' => [ 'integer' => '장바구니 보관기간은 정수여야 합니다.', 'min' => '장바구니 보관기간은 1일 이상이어야 합니다.', diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ShippingPolicyTestApiCallTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ShippingPolicyTestApiCallTest.php index ded07a01..0d07a6e4 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ShippingPolicyTestApiCallTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ShippingPolicyTestApiCallTest.php @@ -228,6 +228,54 @@ class ShippingPolicyTestApiCallTest extends ModuleTestCase 'localhost' => ['http://localhost/calc'], '사설 IP' => ['http://192.168.0.10/calc'], '내부 도메인' => ['http://vault.internal/calc'], + + // 점 동등 유니코드 문자(U+3002·U+FF0E·U+FF61)와 전각 슬래시(U+FF0F)는 연결 + // 계층의 UTS#46 정규화에서 ASCII 로 바뀐다. 게이트가 원문 host 로만 판정하면 + // 검증 시점과 접속 시점의 목적지가 달라져 내부망 조회가 열린다. + 'U+3002 로 감춘 localhost' => ["http://localhost\u{3002}/calc"], + 'U+FF0E 로 감춘 localhost' => ["http://localhost\u{FF0E}/calc"], + 'U+FF61 로 감춘 localhost' => ["http://localhost\u{FF61}/calc"], + 'U+3002 로 감춘 루프백 IP' => ["http://127\u{3002}0\u{3002}0\u{3002}1/calc"], + 'U+FF0E 로 감춘 메타데이터' => ["http://169\u{FF0E}254\u{FF0E}169\u{FF0E}254/latest/meta-data/"], + 'U+3002 로 감춘 내부 도메인' => ["http://vault\u{3002}internal/calc"], + '전각 슬래시로 감춘 루프백' => ["http://127.0.0.1\u{FF0F}.example.com/calc"], + '후행 점 localhost' => ['http://localhost./calc'], + ]; + } + + /** + * 정규화가 정상 외부 API 엔드포인트까지 막지는 않는다 (회귀 방지). + * + * @param string $endpoint 정상 호출 가능한 엔드포인트 + */ + #[DataProvider('legitimateEndpointProvider')] + public function test_public_endpoint_is_still_callable(string $endpoint): void + { + Http::fake(['*' => Http::response(['shipping_fee' => 3000], 200)]); + + $response = $this->actingAs($this->adminUser)->postJson($this->url, [ + 'endpoint' => $endpoint, + 'method' => 'GET', + 'response_format' => 'json', + 'response_path' => 'shipping_fee', + ]); + + $response->assertOk(); + Http::assertSentCount(1); + } + + /** + * 정규화 후에도 통과해야 하는 정상 엔드포인트 목록. + * + * @return array + */ + public static function legitimateEndpointProvider(): array + { + return [ + '공개 도메인' => ['https://api.example.com/shipping/fee'], + '비표준 포트' => ['https://api.example.com:8443/shipping/fee'], + '국제화 도메인' => ["https://\u{4F8B}\u{3048}.jp/shipping/fee"], + 'punycode 도메인' => ['https://xn--r8jz45g.jp/shipping/fee'], ]; } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/admin-product-lookup.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/admin-product-lookup.ts new file mode 100644 index 00000000..aaa11c24 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/admin-product-lookup.ts @@ -0,0 +1,47 @@ +/** + * 관리자 상품폼 E2E 공용 조회 헬퍼. + * + * 추가옵션 선택지(values)를 보유한 상품을 실행 시점에 찾아 그 **숫자 id** 를 돌려준다. + * 상품 id 를 spec 에 상수로 박으면 실측 시드가 정리되는 순간 그 spec 이 통째로 죽는데, + * 죽었다는 사실이 "대상 없음" 과 구분되지 않는다 (실제로 id 306 이 그렇게 사라졌다). + */ +import type { Page } from '@playwright/test'; + +const LIST_API = '/api/modules/sirsoft-ecommerce/admin/products?per_page=40'; +const DETAIL_API = '/api/modules/sirsoft-ecommerce/admin/products'; + +/** + * 추가옵션 선택지를 보유한 상품의 숫자 id 를 찾습니다. + * + * @param page 인증이 적용된 Playwright 페이지 (auth_token 이 localStorage 에 있어야 한다) + * @return 찾은 상품의 숫자 id (없으면 null) + */ +export async function findProductWithAdditionalOptionValues(page: Page): Promise { + return page.evaluate( + async ({ listApi, detailApi }) => { + const token = localStorage.getItem('auth_token') ?? ''; + const headers = { Accept: 'application/json', Authorization: `Bearer ${token}` }; + + const listRes = await fetch(listApi, { headers }); + if (!listRes.ok) return null; + const listJson = await listRes.json(); + const rows = listJson?.data?.data ?? listJson?.data ?? []; + + for (const row of rows) { + const id = Number(row?.id); + if (!Number.isFinite(id)) continue; + + const detailRes = await fetch(`${detailApi}/${id}`, { headers }); + if (!detailRes.ok) continue; + const detail = (await detailRes.json())?.data; + + const groups = detail?.additional_options ?? []; + const hasValues = groups.some((g: any) => (g?.values ?? []).length > 0); + if (hasValues) return id; + } + + return null; + }, + { listApi: LIST_API, detailApi: DETAIL_API }, + ); +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/shop-additional-option-lookup.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/shop-additional-option-lookup.ts new file mode 100644 index 00000000..59216b2e --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/shop-additional-option-lookup.ts @@ -0,0 +1,186 @@ +/** + * 유저 상품상세 추가옵션 흐름 공용 헬퍼 — 대상 상품 조회 + 커스텀 드롭다운 조작. + * + * `additional-options.spec.ts`(선택 payload 축)와 + * `additional-option-currency-conversion.spec.ts`(표시통화 환산 축)가 같은 화면의 같은 + * 조작을 한다. 사본을 각 spec 에 두면 한쪽만 고쳐졌을 때 그 차집합이 사각이 되므로 + * 여기 한 곳에서 소유한다. + * + * 이 템플릿(sirsoft-basic)의 Select 는 `options` 가 있으면 네이티브 `` 가 아니라 + * `button[role=option]` 커스텀 드롭다운을 렌더한다 — `selectOption()` 은 동작하지 않는다. * * 매트릭스 (시나리오 매니페스트 product-additional-options.yaml ui_surface 축과 1:1): - * T1 상품상세: 기본옵션 미선택 → 추가옵션 미노출 (D10) - * T2 기본옵션 선택 → 블럭 내부 활성 선택지만 렌더(V6 비활성 제외), 추가옵션 선택 → 소계·총액 실시간(옵션가+추가옵션×수량) - * T3 같은 옵션 2블럭 → 블럭별 독립 추가옵션, 수량 3 → 추가금×3 (D6) - * T4 담기/바로구매 → additional_option_selections 전송, 필수 미선택 → 422 additional_option_required / 잘못된 value → 422 additional_option_invalid - * T5 장바구니 합산 키 — (옵션+추가옵션 해시) 동일 합산 / 상이 별개 행 (D3) - * T6 새로고침 → 장바구니 추가옵션 영속(CartItemResource.additional_options) - * T7 옵션변경 모달 추가옵션 재선택 → 실시간 재계산 → PATCH → 부모 정합 - * 표시: 체크아웃/주문완료/마이페이지/관리자주문서 스냅샷 별행 (D14), 과거 주문(추가옵션 없음) 깨짐 0 + * T2 기본옵션 선택 → 블럭 내부 활성 선택지만 렌더, 추가옵션 선택 → 총액 실시간 반영 + * T4 담기 요청이 additional_option_selections 를 전송한다 + * T6 담은 뒤 장바구니 행에 선택한 추가옵션이 표시된다 */ -import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth'; +import { test, expect } from '@playwright/test'; +import { + findAdditionalOptionProduct, + pickOption, + pickAllMainOptions, + escapeRegExp, +} from '../../fixtures/shop-additional-option-lookup'; -// 추가옵션 보유 시드 상품 상세 (실 도메인 시드 후 경로 확정) -const PRODUCT_URL = '/shop/products/{ADDOPT_PRODUCT_ID}'; +test.describe('유저 추가옵션 흐름', () => { + test('T2 기본옵션 선택 → 추가옵션 선택 시 총액에 추가금이 반영된다', async ({ page }) => { + await page.goto('/shop'); + const product = await findAdditionalOptionProduct(page); + test.skip(product === null, '메인 옵션 2개 이상 + 추가옵션을 가진 공개 상품이 없어 검증할 수 없습니다'); + test.skip( + (product?.priceAdjustment ?? 0) <= 0, + '추가금이 양수인 추가옵션 선택지가 없어 총액 증가를 검증할 수 없습니다', + ); -test.describe.skip('유저 추가옵션 흐름 (placeholder — data-testid 보강 + 시드 후 활성화)', () => { - test('T2 기본옵션 선택 → 블럭 내부 추가옵션 선택 시 총액이 추가금만큼 증가한다', async ({ page }) => { - await page.goto(PRODUCT_URL); - // 기본옵션 선택 → 블럭 생성 - await page.getByTestId('option-group-0').selectOption({ index: 1 }); - // 추가옵션(각인 추가 +5000) 선택 - await page.getByTestId('add-option-0-1').selectOption({ label: /각인 추가/ }); - // 총액에 +5,000 반영 - await expect(page.getByTestId('purchase-total')).toContainText('5,000'); + await page.goto(product!.url); + await pickAllMainOptions(page, product!.mainValues); + + const before = (await page.getByTestId('purchase-total').innerText()).replace(/[^\d]/g, ''); + await pickOption(page, `add-option-0-${product!.groupId}`, new RegExp(escapeRegExp(product!.valueName))); + await expect + .poll(async () => (await page.getByTestId('purchase-total').innerText()).replace(/[^\d]/g, '')) + .not.toBe(before); }); - test('T4 필수 추가옵션 미선택 시 담기 차단 토스트', async ({ page }) => { - await page.goto(PRODUCT_URL); - await page.getByTestId('option-group-0').selectOption({ index: 1 }); - // 필수 그룹 미선택 상태로 담기 - await page.getByTestId('add-to-cart').click(); - await expect(page.getByText(/필수 추가옵션/)).toBeVisible(); + test('T4 담기 요청이 additional_option_selections 를 전송한다', async ({ page }) => { + await page.goto('/shop'); + const product = await findAdditionalOptionProduct(page); + test.skip(product === null, '메인 옵션 2개 이상 + 추가옵션을 가진 공개 상품이 없어 검증할 수 없습니다'); + + await page.goto(product!.url); + await pickAllMainOptions(page, product!.mainValues); + await pickOption(page, `add-option-0-${product!.groupId}`, new RegExp(escapeRegExp(product!.valueName))); + + const [request] = await Promise.all([ + page.waitForRequest((r) => r.url().includes('/cart') && r.method() === 'POST'), + page.getByTestId('add-to-cart').click(), + ]); + const body = request.postDataJSON(); + expect(body.items?.length, '선택한 옵션이 요청 body 에 실려야 한다').toBeGreaterThan(0); + const selections = body.items?.[0]?.additional_option_selections ?? []; + expect( + selections.some((s: any) => Number(s.additional_option_id) === product!.groupId), + '선택한 추가옵션이 요청 body 에 실려야 한다', + ).toBe(true); }); - test('T7 장바구니 옵션변경 모달에서 추가옵션 재선택 후 PATCH 정합', async ({ page, customerToken }) => { - await authenticatePage(page, customerToken); + test('T6 담은 뒤 장바구니 행에 선택한 추가옵션이 표시된다', async ({ page }) => { + await page.goto('/shop'); + const product = await findAdditionalOptionProduct(page); + test.skip(product === null, '메인 옵션 2개 이상 + 추가옵션을 가진 공개 상품이 없어 검증할 수 없습니다'); + + await page.goto(product!.url); + await pickAllMainOptions(page, product!.mainValues); + await pickOption(page, `add-option-0-${product!.groupId}`, new RegExp(escapeRegExp(product!.valueName))); + + const [response] = await Promise.all([ + page.waitForResponse((r) => r.url().includes('/cart') && r.request().method() === 'POST'), + page.getByTestId('add-to-cart').click(), + ]); + expect(response.status(), '담기가 성공해야 장바구니를 확인할 수 있다').toBeLessThan(300); + await page.goto('/shop/cart'); - await page.getByTestId('cart-change-option').first().click(); - await page.getByTestId('modal-add-option-1').selectOption({ label: /각인 추가/ }); - await page.getByTestId('modal-apply').click(); - // 장바구니 행에 변경된 추가옵션 반영 - await expect(page.getByTestId('cart-item').first()).toContainText(/각인 추가/); + await expect(page.getByTestId('cart-item').first()).toContainText(product!.valueName); }); }); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Console/CancelPendingPaymentOrdersCommandTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Console/CancelPendingPaymentOrdersCommandTest.php index eda0b723..68428622 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Console/CancelPendingPaymentOrdersCommandTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Console/CancelPendingPaymentOrdersCommandTest.php @@ -5,12 +5,14 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Console; use App\Contracts\Extension\ModuleInterface; use App\Contracts\Extension\ModuleManagerInterface; use App\Contracts\Extension\ModuleSettingsInterface; +use App\Extension\HookManager; use App\Models\User; use App\Services\ModuleSettingsService; use Carbon\Carbon; use Mockery; use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; +use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderPayment; use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService; @@ -286,4 +288,164 @@ class CancelPendingPaymentOrdersCommandTest extends ModuleTestCase ->expectsOutput('처리할 만료 주문이 없습니다.') ->assertSuccessful(); } + + /** + * 결제창까지 갔으나 성립하지 않은 주문(PG 카드 등)도 경과 후 정리한다. + * + * 이 부류는 입금 기한이라는 개념이 없어 종전에는 어떤 정리 주체도 없었다. 브라우저 리턴 + * 콜백이 주문 상태를 바꾸지 않게 되면서 승인 거절분도 여기에 머무르므로, 경과 기준으로 + * 정리해 선차감 마일리지가 무기한 묶이지 않게 한다. + */ + public function test_cancels_stale_pending_order_that_never_completed_payment(): void + { + $user = User::factory()->create(); + + $order = Order::factory()->create([ + 'user_id' => $user->id, + 'order_status' => OrderStatusEnum::PENDING_ORDER, + 'ordered_at' => Carbon::now()->subDays(2), + ]); + + OrderPayment::factory()->create([ + 'order_id' => $order->id, + 'payment_method' => PaymentMethodEnum::CARD, + 'payment_status' => PaymentStatusEnum::READY, + ]); + + $this->artisan('sirsoft-ecommerce:cancel-pending-orders') + ->assertSuccessful(); + + $order->refresh(); + $this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status); + } + + /** + * 아직 기한이 지나지 않은 주문대기 주문은 건드리지 않는다 — 구매자가 결제창을 열어 둔 + * 상태일 수 있으므로, 진행 중인 결제를 취소해 버리면 안 된다. + */ + public function test_does_not_cancel_recent_pending_order(): void + { + $user = User::factory()->create(); + + $order = Order::factory()->create([ + 'user_id' => $user->id, + 'order_status' => OrderStatusEnum::PENDING_ORDER, + 'ordered_at' => Carbon::now()->subMinutes(5), + ]); + + OrderPayment::factory()->create([ + 'order_id' => $order->id, + 'payment_method' => PaymentMethodEnum::CARD, + 'payment_status' => PaymentStatusEnum::READY, + ]); + + $this->artisan('sirsoft-ecommerce:cancel-pending-orders') + ->assertSuccessful(); + + $order->refresh(); + $this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status); + } + + /** + * 승인 콜백과 경쟁해 이미 결제가 성립한 주문은 정리 대상이 아니다. + */ + public function test_does_not_cancel_stale_pending_order_whose_payment_is_paid(): void + { + $user = User::factory()->create(); + + $order = Order::factory()->create([ + 'user_id' => $user->id, + 'order_status' => OrderStatusEnum::PENDING_ORDER, + 'ordered_at' => Carbon::now()->subDays(2), + ]); + + OrderPayment::factory()->create([ + 'order_id' => $order->id, + 'payment_method' => PaymentMethodEnum::CARD, + 'payment_status' => PaymentStatusEnum::PAID, + 'paid_at' => Carbon::now()->subDay(), + ]); + + $this->artisan('sirsoft-ecommerce:cancel-pending-orders') + ->assertSuccessful(); + + $order->refresh(); + $this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status); + } + + /** + * 만료 기준을 0 으로 두면 주문대기 주문 정리를 끌 수 있다 (운영자 선택권). + */ + public function test_pending_order_cleanup_can_be_disabled_by_setting(): void + { + $this->moduleSettings['order_settings.pending_order_expire_minutes'] = 0; + + $user = User::factory()->create(); + + $order = Order::factory()->create([ + 'user_id' => $user->id, + 'order_status' => OrderStatusEnum::PENDING_ORDER, + 'ordered_at' => Carbon::now()->subDays(2), + ]); + + OrderPayment::factory()->create([ + 'order_id' => $order->id, + 'payment_method' => PaymentMethodEnum::CARD, + 'payment_status' => PaymentStatusEnum::READY, + ]); + + $this->artisan('sirsoft-ecommerce:cancel-pending-orders') + ->assertSuccessful(); + + $order->refresh(); + $this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status); + } + + /** + * 정리 시 선차감 마일리지가 복원된다 — 이것이 이 정리를 넓힌 이유다. + * + * 마일리지 차감 시점을 '주문할 때'로 설정한 상점에서 카드 승인이 거절되면, 종전에는 + * 콜백의 실패 처리가 복원했다. 그 경로가 위조 가능해 막힌 뒤로는 이 정리가 복원을 맡는다. + */ + public function test_cancelling_stale_pending_order_restores_deducted_mileage(): void + { + $user = User::factory()->create(); + + $order = Order::factory()->create([ + 'user_id' => $user->id, + 'order_status' => OrderStatusEnum::PENDING_ORDER, + 'ordered_at' => Carbon::now()->subDays(2), + 'is_mileage_deducted' => true, + 'total_points_used_amount' => 500, + ]); + + OrderPayment::factory()->create([ + 'order_id' => $order->id, + 'payment_method' => PaymentMethodEnum::CARD, + 'payment_status' => PaymentStatusEnum::READY, + ]); + + // 복원은 마일리지 리스너에 위임되므로, 복원이 일어났다는 신호는 이 훅의 발화다 + // (취소 서비스는 플래그를 되돌리지 않고 취소 레코드 기준으로 멱등성을 보장한다). + $restoredAmounts = []; + HookManager::addAction( + 'sirsoft-ecommerce.mileage.restore', + function ($amount) use (&$restoredAmounts) { + $restoredAmounts[] = $amount; + }, + 10, + ['sync' => true], + ); + + $this->artisan('sirsoft-ecommerce:cancel-pending-orders') + ->assertSuccessful(); + + $order->refresh(); + $this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status); + $this->assertNotEmpty( + $restoredAmounts, + '정리된 주문의 선차감 마일리지 복원이 일어나지 않았습니다.' + ); + $this->assertSame(500, (int) $restoredAmounts[0]); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/pending-order-expiry-cleanup.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/pending-order-expiry-cleanup.yaml new file mode 100644 index 00000000..ff2b6680 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/pending-order-expiry-cleanup.yaml @@ -0,0 +1,75 @@ +# audit:allow test-scenario-coverage reason: | +# 조합·효과 마킹 면제. 룰은 이 매니페스트의 축을 정상 전개한다 — 파서 한계가 아니라 +# 커버 방식이 이유다. 정리 대상 판정은 주문 부류 × 경과 × 결제상태의 곱인데 테스트는 +# 그 곱을 메서드 하나당 한 점씩 찌르는 형태라 조합 단위 @scenario 마킹으로 표현되지 +# 않는다. 본 매니페스트는 "무엇을 반드시 시험해야 하는가" 의 SSoT 로 두고, 실제 커버는 +# test_files 의 통과 테스트가 담당한다. test_files 실재 검사는 면제 대상이 아니다. + +feature: 미결제 주문 만료 자동 정리 (입금기한 부류 + 주문대기 부류) + +description: | + 결제가 성립하지 않은 주문을 정리 배치가 취소로 거두는 계약. + + 종전에는 입금 기한이 있는 결제수단(vbank·dbank)만 정리 대상이었다. 결제창까지 갔으나 + 승인되지 않은 주문(PG 카드 등)은 입금 기한이라는 개념이 없어 어떤 정리 주체도 없이 남았고, + 실무에서는 브라우저 리턴 콜백의 실패 처리가 그 자리를 대신하고 있었다. + + 그 콜백 경로는 인증도 서명도 없어 주문번호만 아는 제3자가 남의 주문을 취소시킬 수 있었고, + 그래서 주문 상태를 바꾸지 않도록 막았다. 그 결과 승인 거절분이 주문대기에 머무르게 되므로, + 이 정리가 그 부류까지 거두고 **선차감 마일리지 복원**도 함께 책임진다. + + 경계: 승인 콜백과 경쟁해 이미 결제가 성립한 주문(paid·입금대기)은 정리하지 않는다. + 운영자는 만료 기준(분)을 0 으로 두어 주문대기 부류의 정리만 끌 수 있고, 그때도 입금기한 + 부류의 정리는 그대로 동작한다. + +axes: + order_class: + - deposit_due_vbank # 가상계좌 — vbank_due_at 도과분 + - deposit_due_dbank # 무통장입금 — deposit_due_at 도과분 + - pending_order_never_paid # 결제창까지 갔으나 승인되지 않은 주문 (PG 카드 등) + elapsed: + - past_threshold # 기준 경과 → 대상 + - within_threshold # 기준 이내 → 구매자가 결제창을 열어 둔 상태일 수 있어 제외 + payment_status: + - ready_or_failed # 결제 미성립 → 대상 + - paid # 승인 콜백과 경쟁해 이미 성립 → 제외 + - waiting_deposit # 입금 대기 → 제외 + expire_minutes_setting: + - positive # 주문대기 부류 정리 활성 (기본 1440) + - zero # 주문대기 부류만 끔 — 입금기한 부류는 계속 정리 + run_mode: + - apply + - dry_run + +exclusions: + - order_class: deposit_due_vbank + expire_minutes_setting: zero + reason: 만료 기준 설정은 주문대기 부류에만 걸린다 — 입금기한 부류의 동작을 바꾸지 않는다 + - order_class: deposit_due_dbank + expire_minutes_setting: zero + reason: 동일 + +effects: + - stale_pending_order_is_cancelled # 기준 경과 + 미성립 → 취소 + - recent_pending_order_is_left_alone # 기준 이내 → 무변경 (진행 중 결제 보호) + - paid_pending_order_is_left_alone # 승인 경쟁 보호 + - waiting_deposit_pending_order_is_left_alone + - expired_vbank_order_is_cancelled + - expired_dbank_order_is_cancelled + - non_expired_order_is_left_alone + - zero_setting_disables_pending_class_only # 운영자 선택권 + - deducted_mileage_is_restored_on_cleanup # 이 정리를 넓힌 이유 + - dry_run_mutates_nothing + - limit_option_is_respected + - disabled_toggle_skips_the_whole_run + - pending_class_due_display_falls_back_to_ordered_at # 입금 기한이 없는 부류의 표기 기준 + +test_files: + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Console/CancelPendingPaymentOrdersCommandTest.php + +manual_verification: + - description: "만료 정리 대상 검출 — 운영 데이터 무변경 확인" + steps: + - "php artisan sirsoft-ecommerce:cancel-pending-orders --dry-run --limit=5" + - "검출 목록에 주문대기 부류가 포함되고, 기한 표기가 주문 시각으로 나오는지 확인" + - "dry-run 이므로 어떤 주문도 상태가 바뀌지 않아야 한다" diff --git a/plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md b/plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md index d4987b49..00e5d506 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md @@ -132,6 +132,12 @@ CBT 인증 URL 로 폼 POST → KG 이니시스가 `sid` 를 콜백으로 전달 | 결제창 서명/모바일 해시/CBT 해시 요청에 타임스탬프 검증 생략 | 타임스탬프 신선도 검증 유지 | 오래된 서명 재사용(replay)으로 위조 결제 요청이 통과할 수 있다 | | 일본 결제 설정 미완료 시 한국 표준결제로 조용히 대체 | 설정 미완료면 결제 자체를 중단 | 통화·수수료·정산 구조가 다른 결제가 잘못된 흐름으로 승인될 수 있다 | | 라이브 키(사인키·INIAPI 키/IV·해시키)를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 결제창 서명을 위조할 수 있다 | +| 서버 승인 실패 분기(PC `authorizePayment` · 모바일 `P_STATUS` · CBT `approveCbtPayment`)에서 `failPayment()` 호출 | 로그 + `resolveFailUrl()` 만. 주문 상태는 건드리지 않는다 | 세 콜백 모두 PG 서명도 IP 증명도 없는 비인증 브라우저 요청이고 주문번호(`MOID`/`P_OID`/`oid`)도 요청자가 고른 값이다. 승인 실패는 위조 `authToken`/`P_TID`/`sid` 만으로 만들어낼 수 있으므로, 그것을 근거로 실패 처리하면 타인의 결제대기 주문이 취소된다 | +| 정당한 결제 실패 기록을 콜백에서 처리 | 소유권을 검증하는 `close-report`(`requestMatchesOrderBuyer`) 경유 | 구매자 이메일·전화 대조를 통과한 요청만 주문 상태를 바꿔야 한다 | +| PG 대상 `netCancel` 을 로컬 주문 실패 처리와 같은 것으로 취급 | `sendNetCancel()` 은 PG 잔존 승인 해제이므로 유지, 로컬 주문 mutation 은 별개 판단 | 두 동작을 묶으면 PG 정합성을 지키려다 주문 취소 통로를 다시 연다 | +| 결제창 컨텍스트를 `window` 전역에만 보관 | `markStandardPaymentCloseReportContext()` 가 sessionStorage 에도 남기고, 부팅 시 `reportStandardPaymentFailureOnReturn()` 으로 보고 | 결제창은 전체 페이지 이동으로 열리고 돌아와 전역이 소실된다. 승인 거절은 fail URL 리다이렉트로 끝나므로, 남겨 둔 정보가 없으면 정당한 결제 실패가 어디에도 기록되지 않는다 | +| 리턴 콜백 복귀 보고에 체크아웃 경로 검사를 강제 | `reportStandardPaymentWindowClosed($reason, requireCheckoutPage: false)` | 상점이 `redirect_fail_url` 을 바꿔 두면 경로 검사가 보고를 통째로 막는다. 닫힘 메시지 경로(체크아웃 화면 전용)와 리턴 복귀 경로는 판정 기준이 다르다 | +| 실패 화면에서 보고가 닿지 못한 주문을 방치 | 이커머스 모듈의 만료 주문 자동 정리가 최종 안전망 | 브라우저를 바로 닫으면 보고가 나가지 않는다. 두 경로가 함께 있어야 선차감 마일리지가 무기한 묶이지 않는다 | ## 7. 테스트 실행 @@ -141,7 +147,7 @@ CBT 인증 URL 로 폼 POST → KG 이니시스가 `sid` 를 콜백으로 전달 |---|---|---| | PHPUnit | 35개 | `plugins/_bundled/sirsoft-pay_kginicis/tests` | | Vitest | 12개 | `vitest.config.ts` | -| Playwright | 0개 | — | +| Playwright | 1개 | `tests/Playwright` | | 시나리오 매니페스트 | 2개 | `tests/scenarios` | 기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지). @@ -153,6 +159,9 @@ php vendor/bin/phpunit plugins/_bundled/sirsoft-pay_kginicis/tests --filter='< # Vitest (확장 디렉토리에서) (PowerShell) cd plugins/_bundled/sirsoft-pay_kginicis && powershell -Command "npm run test:run -- <대상>" +# Playwright E2E (확장 디렉토리에서) (Bash) +cd plugins/_bundled/sirsoft-pay_kginicis && npm run test:e2e -- specs/<대상>.spec.ts + ``` 무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md index 9abf5077..2b9f8275 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md @@ -6,8 +6,13 @@ ## [1.1.3] - 2026-08-31 +### Security + +- 제3자가 남의 주문번호만 알면 결제창을 거치지 않고도 그 주문을 취소시킬 수 있던 문제를 수정했습니다. 결제 결과 콜백은 로그인도 서명 확인도 거치지 않는 경로여서, 위조한 인증 정보를 보내 승인을 일부러 실패시키면 그 주문이 결제 실패로 처리되었습니다. 이제 승인이 성립하지 않은 콜백은 주문 상태를 바꾸지 않고 결제 화면으로 되돌려 보내기만 합니다. PC·모바일·해외결제(CBT) 결제창 모두에 적용됩니다. 구매자가 결제창을 닫아 생기는 정상적인 결제 실패는 종전처럼 기록됩니다. + ### Added +- 결제가 거절되어 실패 화면으로 돌아오면 그 사실이 자동으로 서버에 기록됩니다. 구매자 본인인지 확인한 뒤에만 주문을 실패로 처리하므로, 남의 주문번호를 아는 것만으로는 그 주문을 건드릴 수 없습니다. PC·모바일·해외결제 결제창 모두에 적용되며, 상점이 실패 안내 주소를 바꿔 두었어도 동작합니다. - 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. - 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. - 문서의 제품 표기를 「그누보드7」로 통일했습니다. diff --git a/plugins/_bundled/sirsoft-pay_kginicis/components.json b/plugins/_bundled/sirsoft-pay_kginicis/components.json index 6365c88b..b9fdb540 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/components.json +++ b/plugins/_bundled/sirsoft-pay_kginicis/components.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "identifier": "sirsoft-pay_kginicis", - "version": "1.1.0", + "version": "1.1.3", "components": { "basic": [], "composite": [], diff --git a/plugins/_bundled/sirsoft-pay_kginicis/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-pay_kginicis/dist/js/plugin.iife.js index f5678ff6..fae63feb 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/dist/js/plugin.iife.js +++ b/plugins/_bundled/sirsoft-pay_kginicis/dist/js/plugin.iife.js @@ -1,14 +1,14 @@ -(function(){"use strict";const x="kginicis_pay_form_",R="__sirsoftKginicisReloadStandardPaySdk",w="__sirsoftKginicisMobilePaymentReturnPending";function ie(){if(typeof document>"u")return 0;const e=document.querySelectorAll(`form[id^="${x}"]`);return e.forEach(t=>t.remove()),e.length}function qe(){typeof window>"u"||(window[R]=!0)}function We(){if(typeof window>"u")return!1;const e=window,t=e[R]===!0;return delete e[R],t}function Ve(e){typeof window>"u"||typeof document>"u"||(window.INIStdPay=void 0,document.querySelectorAll(`script[src="${e}"], script[src*="/INIStdPay_third-party.js"]`).forEach(t=>t.remove()))}function ze(e=Date.now()){if(!(typeof window>"u"))try{window.sessionStorage.setItem(w,String(e))}catch{window[w]=e}}function M(){if(!(typeof window>"u")){try{window.sessionStorage.removeItem(w)}catch{}delete window[w]}}function ae(e=Date.now()){if(typeof window>"u")return!1;let t=null;try{const n=window.sessionStorage.getItem(w);t=n?Number(n):null}catch{t=null}if(!Number.isFinite(t)){const n=window[w];t=typeof n=="number"?n:Number(n)}return!Number.isFinite(t)||e-t>18e5?(M(),!1):!0}const N="sirsoft-pay_kginicis",Xe=N,Ze="payment-window-closed",se="__sirsoftKginicisPaymentCloseListenerInstalled",L="__sirsoftKginicisActiveStandardPaymentCloseContext",ce=20,Qe=100,O={info:(...e)=>console.info(`[${N}]`,...e),warn:(...e)=>console.warn(`[${N}]`,...e)};function et(e){if(!e||typeof e!="object")return!1;const t=e;return t.source===Xe&&t.type===Ze}function $(){return/\/shop\/checkout\/?$/.test(window.location.pathname)}function P(){return window}function tt(){const e=P()[L];return!e||typeof e!="object"?null:e}function nt(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function rt(e){e.closeReportUrl&&(P()[L]={...e,reported:!1})}function de(){delete P()[L]}function le(){de()}async function ot(e="payment-window-closed"){const t=tt();if(!t||t.reported||!$())return;t.reported=!0;const n={oid:t.oid,price:t.price,buyer_email:t.buyer_email??"",buyer_phone:t.buyer_phone??"",payment_method:t.payment_method??"",reason:e};try{const r=window.G7Core?.api;typeof r?.post=="function"?await r.post(t.closeReportUrl,n):await fetch(nt(t.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(r){O.warn("failed to report KG payment window close",{reason:e,error:r})}finally{de()}}function ue(e="payment-window-closed",t=!0){if(!$())return!1;const r=window.G7Core?.state?.setLocal;return typeof r!="function"?(t&&O.warn("G7Core.state.setLocal not available while resetting payment submit state"),!1):(ie(),qe(),r({isSubmittingOrder:!1}),O.info("checkout submit state reset after KG payment close",{reason:e}),!0)}function it(e,t=!1){let n=0;const r=()=>{if(n++,!(t&&!ae())){if(ue(e,n>=ce)){t&&M();return}!$()||n>=ce||window.setTimeout(r,Qe)}};r()}function I(e){ae()&&it(e,!0)}function at(){if(typeof window>"u")return;const e=P();e[se]||(window.addEventListener("message",t=>{t.origin===window.location.origin&&et(t.data)&&(ot(t.data.reason),ue(t.data.reason))}),window.addEventListener("pagehide",()=>{le()}),window.addEventListener("beforeunload",()=>{le()}),window.addEventListener("pageshow",t=>{I(t.persisted?"mobile-payment-bfcache-return":"mobile-payment-page-show")}),window.addEventListener("focus",()=>{I("mobile-payment-window-focus")}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="visible"&&I("mobile-payment-visibility-return")}),e[se]=!0,I("mobile-payment-listener-installed"))}function me(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(e.userAgent||"").toLowerCase(),n=((e.userAgentData?.platform??e.platform)||"").toLowerCase();if(/android|iphone|ipad|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||/iphone|ipad|ipod|ios/.test(n))return!0;const r=e.maxTouchPoints??0;return/macintosh|mac os x/.test(t)&&r>1}function st(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const r=document.createElement("script");r.src=e,r.async=!0,r.onload=()=>t(),r.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(r)})}function fe(e,t){if(!t.startsWith("kginicis_"))return e;const n=new URL(e,window.location.origin);return n.searchParams.set("selectedPaymentMethod",t),n.toString()}function ct(e){const t=(e??"").trim().toUpperCase();return t===""||t==="WON"?"KRW":t}function pe(e,t,n="utf-8",r){const o=document.createElement("form");r&&(o.id=r),o.method="POST",o.action=e,o.acceptCharset=n,o.style.display="none";for(const[i,s]of Object.entries(t)){const a=document.createElement("input");a.type="hidden",a.name=i,a.value=s,o.appendChild(a)}document.body.appendChild(o),o.submit()}const dt={card:"Card",vbank:"VBank",bank:"DirectBank",phone:"HPP",kginicis_samsung_pay:"onlyssp",kginicis_naverpay:"onlynaverpay",kginicis_lpay:"onlylpay",kginicis_kakaopay:"onlykakaopay"},lt={card:"CARD",vbank:"VBANK",bank:"BANK",phone:"MOBILE"},ut={kginicis_samsung_pay:"d_samsungpay=Y",kginicis_naverpay:"d_npay=Y",kginicis_lpay:"d_lpay=Y",kginicis_kakaopay:"d_kakaopay=Y"},mt=["CARD","CVS","PAYpay"],ye={card:["CARD"],kginicis_japan_paypay:["PAYpay"],kginicis_japan_cvs:["CVS"]},ft=new Set(Object.keys(ye)),pt=new Set(["kginicis_samsung_pay","kginicis_naverpay","kginicis_lpay","kginicis_kakaopay"]);async function yt(e,t,n,r){const o=String(Math.floor(Date.now())),i=await e.api.post(t.callback_urls.mobile_signature,{oid:n.order_number,price:n.amount,timestamp:o,buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??""}),{chkfake:s,mobile_payment_url:a}=i.data,c=fe(window.location.origin+t.callback_urls.mobile_callback+"?orderId="+encodeURIComponent(n.order_number),r),u=ut[r],d=u!==void 0,l=d?a.replace(/\/smart\/[^/]+\/?$/,"/smart/wcard/"):a,m=lt[r]??"CARD",f=t.use_escrow?"below1000=Y&vbank_receipt=Y&useescrow=Y¢erCd=Y&amt_hash=Y":"below1000=Y&vbank_receipt=Y¢erCd=Y&amt_hash=Y",p=d?f.replace("&useescrow=Y","")+"&"+u:f,y={P_MID:t.mid,P_OID:n.order_number,P_AMT:String(n.amount),P_GOODS:n.order_name,P_UNAME:n.customer_name??"",P_MOBILE:n.customer_phone??"",P_EMAIL:n.customer_email??"",P_NEXT_URL:c,P_CHARSET:"utf8",P_TIMESTAMP:o,P_CHKFAKE:s,P_RESERVED:p};d?y.P_SKIP_TERMS="Y":y.P_INI_PAYMENT=m,m==="MOBILE"&&!d&&(y.P_HPP_METHOD="2"),m==="VBANK"&&!d&&(y.P_NOTI_URL=window.location.origin+t.callback_urls.mobile_vbank_notify),ze(),pe(l,y,"euc-kr",x+"mobile_"+Date.now())}async function _t(e,t,n,r){const o=String(Math.floor(Date.now())),i=await e.api.post(t.callback_urls.signature,{oid:n.order_number,price:n.amount,timestamp:o,buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??""}),{signature:s,verification:a,mKey:c}=i.data;if(We()&&Ve(t.sdk_url),window.INIStdPay||await st(t.sdk_url),window.INIStdPay||await new Promise(p=>setTimeout(p,100)),!window.INIStdPay)throw new Error("INIStdPay SDK not available");const u=fe(window.location.origin+t.callback_urls.callback,r),d=window.location.origin+t.callback_urls.close;ie();const l=x+Date.now(),m=document.createElement("form");m.id=l,m.method="POST",m.acceptCharset="euc-kr";const f={version:"1.0",mid:t.mid,oid:n.order_number,goodname:n.order_name,price:String(n.amount),currency:"WON",buyername:n.customer_name??"",buyeremail:n.customer_email??"",buyertel:n.customer_phone??"",timestamp:o,signature:s,verification:a,mKey:c,returnUrl:u,closeUrl:d,gopaymethod:dt[r]??"Card",acceptmethod:(()=>{const p=t.use_escrow?"useescrow:":"",y=t.use_credit_point?"CREDITCARD(Y):":"",h=r==="phone"?`HPP(1):${p}${y}centerCd(Y)`:`${p}${y}centerCd(Y)`;return r==="kginicis_samsung_pay"||r==="kginicis_naverpay"||r==="kginicis_lpay"||r==="kginicis_kakaopay"?h?`${h}:cardonly`:"cardonly":h})(),payViewType:"overlay",use_chkfake:"Y",charset:"UTF-8"};for(const[p,y]of Object.entries(f)){const h=document.createElement("input");h.type="hidden",h.name=p,h.value=y,m.appendChild(h)}document.body.appendChild(m),t.callback_urls.close_report&&rt({closeReportUrl:t.callback_urls.close_report,oid:n.order_number,price:Number(n.amount),buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??"",payment_method:r}),window.INIStdPay.pay(l)}function ht(e=new Date){const t=n=>String(n).padStart(2,"0");return e.getFullYear().toString()+t(e.getMonth()+1)+t(e.getDate())+t(e.getHours())+t(e.getMinutes())+t(e.getSeconds())}function gt(e,t){const n=e.cbt_extra_data??{},o=(t?ye[t]:void 0)??n.payment?.paymethod??mt,i=window.location.origin+e.callback_urls.cbt_cvs_notify;return{...n,paymentUI:{language:"JP",...n.paymentUI??{}},payment:{...n.payment??{},paymethod:o,isMobile:me()?"true":"false",cvs:{...n.payment?.cvs??{},notiUrl:i}}}}async function wt(e,t,n,r){const o=t.japan_mid,i=ht(),s=n.customer_email??"",a=n.customer_phone??"",c=await e.api.post(t.callback_urls.cbt_checkout_token,{oid:n.order_number,price:n.amount,buyer_email:s,buyer_phone:a}),{checkout_token:u}=c.data,d=await e.api.post(t.callback_urls.cbt_hash_data,{oid:n.order_number,price:n.amount,timestamp:i,buyer_email:s,buyer_phone:a,checkout_token:u}),{hash_data:l}=d.data,m=window.location.origin+t.callback_urls.cbt_callback+`?oid=${encodeURIComponent(n.order_number)}&selectedPaymentMethod=${encodeURIComponent(r??"card")}`;pe(t.callback_urls.cbt_auth_url,{cbtType:"JPPG",mid:o,timestamp:i,returnUrl:m,buyerName:n.customer_name??"",buyerTel:n.customer_phone??"",buyerEmail:n.customer_email??"",goodName:n.order_name,amount:String(n.amount),orderId:n.order_number,hashData:l,extraData:JSON.stringify(gt(t,r))})}async function bt(e,t){const{pgPaymentData:n,paymentMethod:r}=e.params||{};if(!n)return;const o=window.__templateApp?.globalState?._local,i=r??n.payment_method??o?.paymentMethod??"card",s=window.G7Core;try{const a=await s.api.get("/modules/sirsoft-ecommerce/payments/client-config/kginicis");if(!a.data)throw new Error("Failed to fetch KG Inicis client config");const c=a.data,u=ct(n.currency),d=u==="JPY",l=u==="KRW",m=i.startsWith("kginicis_japan_"),f=pt.has(i),p=c.japan_enabled&&!!c.japan_mid&&c.japan_configured!==!1,y=c.japan_restrict_jpy_payment_methods===!0;if(m&&!d)throw new Error("KG Inicis Japan payment methods require a JPY order.");if(f&&Array.isArray(c.easy_pay_enabled_methods)&&!c.easy_pay_enabled_methods.includes(i))throw new Error("Selected KG Inicis easy pay method is disabled.");if(!d&&!l)throw new Error("KG Inicis supports only KRW standard payments or JPY Japan CBT payments.");if(d&&!p)throw new Error("KG Inicis Japan CBT payment is not configured.");if(d&&y&&!ft.has(i))throw new Error("JPY orders can only use KG Inicis Japan CBT payment methods.");const h=me();if(l&&c.standard_configured===!1)throw new Error("KG Inicis live standard payment is not configured.");if(l&&h&&c.mobile_configured===!1)throw new Error("KG Inicis live mobile payment is not configured.");d?await wt(s,c,n,i):h?await yt(s,c,n,i):await _t(s,c,n,i)}catch(a){const c=a instanceof Error?a.message:"Unknown error";s?.state?.setLocal?.({paymentErrorMessage:c,isSubmittingOrder:!1,paymentMethod:i}),s?.modal?.open?.("kginicis_payment_error_modal")}}const _e={requestPayment:bt},kt="sirsoft-pay_kginicis",Ct={info:(...e)=>console.info(`[${kt}]`,...e)};function Et(){Ct.info("order interceptor is a no-op — payment entry is dispatched via pg_payment_handler")}const St="sirsoft-pay_kginicis";function Pt(){return localStorage.getItem("auth_token")}function It(){try{const t=sessionStorage.getItem("g7_guest_order_token");if(t)return t}catch{}const e=window.G7Core?.state?.get?.("_global")?.guestOrderToken;return typeof e=="string"&&e!==""?e:null}async function he(e,t){try{const n=await fetch(`/api/plugins/${St}/user/orders/${e}/receipt`,{headers:t,credentials:"same-origin"});return n.ok?{status:n.status,data:await n.json()}:{status:n.status,data:null}}catch{return{status:0,data:null}}}async function ge(e){const t=Pt(),n=It(),r={Accept:"application/json"};t?r.Authorization=`Bearer ${t}`:n&&(r["X-Guest-Order-Token"]=n);const o=await he(e,r);if(o.data||o.status!==401||!t)return{status:o.status,info:o.data};const i={Accept:"application/json"};n&&(i["X-Guest-Order-Token"]=n);const s=await he(e,i);return{status:s.status,info:s.data}}async function v(e){return(await ge(e)).info}function C(e){return e?e.receipt_url?!0:e.receipt_type==="cbt_confirmation"&&Array.isArray(e.receipt_fields)&&e.receipt_fields.length>0:!1}function A(e){return e?.receipt_view_label||"영수증 조회"}function vt(e){return e?.receipt_label||"영수증"}function we(e){if(e.receipt_url){window.open(e.receipt_url,"kginicis_receipt","width=800,height=600,scrollbars=yes,resizable=yes");return}e.receipt_type==="cbt_confirmation"&&At(e)}function At(e){const t=window.open("","kginicis_receipt","width=800,height=700,scrollbars=yes,resizable=yes");if(!t)return;const n=e.receipt_title||"KG 이니시스 CBT 결제확인서",r=e.receipt_notice||"",o=(e.receipt_fields??[]).map(i=>` +(function(){"use strict";const x="kginicis_pay_form_",M="__sirsoftKginicisReloadStandardPaySdk",w="__sirsoftKginicisMobilePaymentReturnPending";function ce(){if(typeof document>"u")return 0;const e=document.querySelectorAll(`form[id^="${x}"]`);return e.forEach(t=>t.remove()),e.length}function ze(){typeof window>"u"||(window[M]=!0)}function Xe(){if(typeof window>"u")return!1;const e=window,t=e[M]===!0;return delete e[M],t}function Ze(e){typeof window>"u"||typeof document>"u"||(window.INIStdPay=void 0,document.querySelectorAll(`script[src="${e}"], script[src*="/INIStdPay_third-party.js"]`).forEach(t=>t.remove()))}function Qe(e=Date.now()){if(!(typeof window>"u"))try{window.sessionStorage.setItem(w,String(e))}catch{window[w]=e}}function N(){if(!(typeof window>"u")){try{window.sessionStorage.removeItem(w)}catch{}delete window[w]}}function de(e=Date.now()){if(typeof window>"u")return!1;let t=null;try{const n=window.sessionStorage.getItem(w);t=n?Number(n):null}catch{t=null}if(!Number.isFinite(t)){const n=window[w];t=typeof n=="number"?n:Number(n)}return!Number.isFinite(t)||e-t>18e5?(N(),!1):!0}const L="sirsoft-pay_kginicis",et=L,tt="payment-window-closed",le="__sirsoftKginicisPaymentCloseListenerInstalled",I="__sirsoftKginicisActiveStandardPaymentCloseContext",ue=20,nt=100,O={info:(...e)=>console.info(`[${L}]`,...e),warn:(...e)=>console.warn(`[${L}]`,...e)};function rt(e){if(!e||typeof e!="object")return!1;const t=e;return t.source===et&&t.type===tt}function $(){return/\/shop\/checkout\/?$/.test(window.location.pathname)}function C(){return window}function ot(){const e=C()[I];return!e||typeof e!="object"?null:e}function it(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}const D="g7:sirsoft-pay_kginicis:pendingClose";function G(){try{return window.sessionStorage??null}catch{return null}}function at(e){if(e.closeReportUrl){C()[I]={...e,reported:!1};try{G()?.setItem(D,JSON.stringify({...e,reported:!1}))}catch{}}}function K(){delete C()[I];try{G()?.removeItem(D)}catch{}}function st(){try{const e=G()?.getItem(D);if(!e)return null;const t=JSON.parse(e);return t&&typeof t.oid=="string"&&t.oid!==""&&t.closeReportUrl?t:null}catch{return null}}async function ct(){const e=st();if(!e)return;let t;try{t=new URLSearchParams(window.location.search)}catch{return}const n=t.get("orderId")??"";if(n!==""&&n!==e.oid)return;if(/\/(complete|success)(\/|$|\?)/.test(window.location.pathname)){K();return}const r=t.get("error")??"",o=t.get("message")??"";r===""&&n===""||(C()[I]={...e,reported:!1},await fe(o!==""?o:r||"payment-window-closed",!1))}function me(){K()}async function fe(e="payment-window-closed",t=!0){const n=ot();if(!n||n.reported||t&&!$())return;n.reported=!0;const r={oid:n.oid,price:n.price,buyer_email:n.buyer_email??"",buyer_phone:n.buyer_phone??"",payment_method:n.payment_method??"",reason:e};try{const o=window.G7Core?.api;typeof o?.post=="function"?await o.post(n.closeReportUrl,r):await fetch(it(n.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r),keepalive:!0})}catch(o){O.warn("failed to report KG payment window close",{reason:e,error:o})}finally{K()}}function pe(e="payment-window-closed",t=!0){if(!$())return!1;const r=window.G7Core?.state?.setLocal;return typeof r!="function"?(t&&O.warn("G7Core.state.setLocal not available while resetting payment submit state"),!1):(ce(),ze(),r({isSubmittingOrder:!1}),O.info("checkout submit state reset after KG payment close",{reason:e}),!0)}function dt(e,t=!1){let n=0;const r=()=>{if(n++,!(t&&!de())){if(pe(e,n>=ue)){t&&N();return}!$()||n>=ue||window.setTimeout(r,nt)}};r()}function v(e){de()&&dt(e,!0)}function lt(){if(typeof window>"u")return;const e=C();e[le]||(window.addEventListener("message",t=>{t.origin===window.location.origin&&rt(t.data)&&(fe(t.data.reason),pe(t.data.reason))}),window.addEventListener("pagehide",()=>{me()}),window.addEventListener("beforeunload",()=>{me()}),window.addEventListener("pageshow",t=>{v(t.persisted?"mobile-payment-bfcache-return":"mobile-payment-page-show")}),window.addEventListener("focus",()=>{v("mobile-payment-window-focus")}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="visible"&&v("mobile-payment-visibility-return")}),e[le]=!0,v("mobile-payment-listener-installed"))}function ye(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(e.userAgent||"").toLowerCase(),n=((e.userAgentData?.platform??e.platform)||"").toLowerCase();if(/android|iphone|ipad|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||/iphone|ipad|ipod|ios/.test(n))return!0;const r=e.maxTouchPoints??0;return/macintosh|mac os x/.test(t)&&r>1}function ut(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const r=document.createElement("script");r.src=e,r.async=!0,r.onload=()=>t(),r.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(r)})}function _e(e,t){if(!t.startsWith("kginicis_"))return e;const n=new URL(e,window.location.origin);return n.searchParams.set("selectedPaymentMethod",t),n.toString()}function mt(e){const t=(e??"").trim().toUpperCase();return t===""||t==="WON"?"KRW":t}function he(e,t,n="utf-8",r){const o=document.createElement("form");r&&(o.id=r),o.method="POST",o.action=e,o.acceptCharset=n,o.style.display="none";for(const[i,s]of Object.entries(t)){const a=document.createElement("input");a.type="hidden",a.name=i,a.value=s,o.appendChild(a)}document.body.appendChild(o),o.submit()}const ft={card:"Card",vbank:"VBank",bank:"DirectBank",phone:"HPP",kginicis_samsung_pay:"onlyssp",kginicis_naverpay:"onlynaverpay",kginicis_lpay:"onlylpay",kginicis_kakaopay:"onlykakaopay"},pt={card:"CARD",vbank:"VBANK",bank:"BANK",phone:"MOBILE"},yt={kginicis_samsung_pay:"d_samsungpay=Y",kginicis_naverpay:"d_npay=Y",kginicis_lpay:"d_lpay=Y",kginicis_kakaopay:"d_kakaopay=Y"},_t=["CARD","CVS","PAYpay"],ge={card:["CARD"],kginicis_japan_paypay:["PAYpay"],kginicis_japan_cvs:["CVS"]},ht=new Set(Object.keys(ge)),gt=new Set(["kginicis_samsung_pay","kginicis_naverpay","kginicis_lpay","kginicis_kakaopay"]);async function wt(e,t,n,r){const o=String(Math.floor(Date.now())),i=await e.api.post(t.callback_urls.mobile_signature,{oid:n.order_number,price:n.amount,timestamp:o,buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??""}),{chkfake:s,mobile_payment_url:a}=i.data,c=_e(window.location.origin+t.callback_urls.mobile_callback+"?orderId="+encodeURIComponent(n.order_number),r),u=yt[r],d=u!==void 0,l=d?a.replace(/\/smart\/[^/]+\/?$/,"/smart/wcard/"):a,m=pt[r]??"CARD",f=t.use_escrow?"below1000=Y&vbank_receipt=Y&useescrow=Y¢erCd=Y&amt_hash=Y":"below1000=Y&vbank_receipt=Y¢erCd=Y&amt_hash=Y",p=d?f.replace("&useescrow=Y","")+"&"+u:f,y={P_MID:t.mid,P_OID:n.order_number,P_AMT:String(n.amount),P_GOODS:n.order_name,P_UNAME:n.customer_name??"",P_MOBILE:n.customer_phone??"",P_EMAIL:n.customer_email??"",P_NEXT_URL:c,P_CHARSET:"utf8",P_TIMESTAMP:o,P_CHKFAKE:s,P_RESERVED:p};d?y.P_SKIP_TERMS="Y":y.P_INI_PAYMENT=m,m==="MOBILE"&&!d&&(y.P_HPP_METHOD="2"),m==="VBANK"&&!d&&(y.P_NOTI_URL=window.location.origin+t.callback_urls.mobile_vbank_notify),Qe(),he(l,y,"euc-kr",x+"mobile_"+Date.now())}async function bt(e,t,n,r){const o=String(Math.floor(Date.now())),i=await e.api.post(t.callback_urls.signature,{oid:n.order_number,price:n.amount,timestamp:o,buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??""}),{signature:s,verification:a,mKey:c}=i.data;if(Xe()&&Ze(t.sdk_url),window.INIStdPay||await ut(t.sdk_url),window.INIStdPay||await new Promise(p=>setTimeout(p,100)),!window.INIStdPay)throw new Error("INIStdPay SDK not available");const u=_e(window.location.origin+t.callback_urls.callback,r),d=window.location.origin+t.callback_urls.close;ce();const l=x+Date.now(),m=document.createElement("form");m.id=l,m.method="POST",m.acceptCharset="euc-kr";const f={version:"1.0",mid:t.mid,oid:n.order_number,goodname:n.order_name,price:String(n.amount),currency:"WON",buyername:n.customer_name??"",buyeremail:n.customer_email??"",buyertel:n.customer_phone??"",timestamp:o,signature:s,verification:a,mKey:c,returnUrl:u,closeUrl:d,gopaymethod:ft[r]??"Card",acceptmethod:(()=>{const p=t.use_escrow?"useescrow:":"",y=t.use_credit_point?"CREDITCARD(Y):":"",h=r==="phone"?`HPP(1):${p}${y}centerCd(Y)`:`${p}${y}centerCd(Y)`;return r==="kginicis_samsung_pay"||r==="kginicis_naverpay"||r==="kginicis_lpay"||r==="kginicis_kakaopay"?h?`${h}:cardonly`:"cardonly":h})(),payViewType:"overlay",use_chkfake:"Y",charset:"UTF-8"};for(const[p,y]of Object.entries(f)){const h=document.createElement("input");h.type="hidden",h.name=p,h.value=y,m.appendChild(h)}document.body.appendChild(m),t.callback_urls.close_report&&at({closeReportUrl:t.callback_urls.close_report,oid:n.order_number,price:Number(n.amount),buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??"",payment_method:r}),window.INIStdPay.pay(l)}function kt(e=new Date){const t=n=>String(n).padStart(2,"0");return e.getFullYear().toString()+t(e.getMonth()+1)+t(e.getDate())+t(e.getHours())+t(e.getMinutes())+t(e.getSeconds())}function Ct(e,t){const n=e.cbt_extra_data??{},o=(t?ge[t]:void 0)??n.payment?.paymethod??_t,i=window.location.origin+e.callback_urls.cbt_cvs_notify;return{...n,paymentUI:{language:"JP",...n.paymentUI??{}},payment:{...n.payment??{},paymethod:o,isMobile:ye()?"true":"false",cvs:{...n.payment?.cvs??{},notiUrl:i}}}}async function Et(e,t,n,r){const o=t.japan_mid,i=kt(),s=n.customer_email??"",a=n.customer_phone??"",c=await e.api.post(t.callback_urls.cbt_checkout_token,{oid:n.order_number,price:n.amount,buyer_email:s,buyer_phone:a}),{checkout_token:u}=c.data,d=await e.api.post(t.callback_urls.cbt_hash_data,{oid:n.order_number,price:n.amount,timestamp:i,buyer_email:s,buyer_phone:a,checkout_token:u}),{hash_data:l}=d.data,m=window.location.origin+t.callback_urls.cbt_callback+`?oid=${encodeURIComponent(n.order_number)}&selectedPaymentMethod=${encodeURIComponent(r??"card")}`;he(t.callback_urls.cbt_auth_url,{cbtType:"JPPG",mid:o,timestamp:i,returnUrl:m,buyerName:n.customer_name??"",buyerTel:n.customer_phone??"",buyerEmail:n.customer_email??"",goodName:n.order_name,amount:String(n.amount),orderId:n.order_number,hashData:l,extraData:JSON.stringify(Ct(t,r))})}async function St(e,t){const{pgPaymentData:n,paymentMethod:r}=e.params||{};if(!n)return;const o=window.__templateApp?.globalState?._local,i=r??n.payment_method??o?.paymentMethod??"card",s=window.G7Core;try{const a=await s.api.get("/modules/sirsoft-ecommerce/payments/client-config/kginicis");if(!a.data)throw new Error("Failed to fetch KG Inicis client config");const c=a.data,u=mt(n.currency),d=u==="JPY",l=u==="KRW",m=i.startsWith("kginicis_japan_"),f=gt.has(i),p=c.japan_enabled&&!!c.japan_mid&&c.japan_configured!==!1,y=c.japan_restrict_jpy_payment_methods===!0;if(m&&!d)throw new Error("KG Inicis Japan payment methods require a JPY order.");if(f&&Array.isArray(c.easy_pay_enabled_methods)&&!c.easy_pay_enabled_methods.includes(i))throw new Error("Selected KG Inicis easy pay method is disabled.");if(!d&&!l)throw new Error("KG Inicis supports only KRW standard payments or JPY Japan CBT payments.");if(d&&!p)throw new Error("KG Inicis Japan CBT payment is not configured.");if(d&&y&&!ht.has(i))throw new Error("JPY orders can only use KG Inicis Japan CBT payment methods.");const h=ye();if(l&&c.standard_configured===!1)throw new Error("KG Inicis live standard payment is not configured.");if(l&&h&&c.mobile_configured===!1)throw new Error("KG Inicis live mobile payment is not configured.");d?await Et(s,c,n,i):h?await wt(s,c,n,i):await bt(s,c,n,i)}catch(a){const c=a instanceof Error?a.message:"Unknown error";s?.state?.setLocal?.({paymentErrorMessage:c,isSubmittingOrder:!1,paymentMethod:i}),s?.modal?.open?.("kginicis_payment_error_modal")}}const we={requestPayment:St},Pt="sirsoft-pay_kginicis",It={info:(...e)=>console.info(`[${Pt}]`,...e)};function vt(){It.info("order interceptor is a no-op — payment entry is dispatched via pg_payment_handler")}const At="sirsoft-pay_kginicis";function Tt(){return localStorage.getItem("auth_token")}function Rt(){try{const t=sessionStorage.getItem("g7_guest_order_token");if(t)return t}catch{}const e=window.G7Core?.state?.get?.("_global")?.guestOrderToken;return typeof e=="string"&&e!==""?e:null}async function be(e,t){try{const n=await fetch(`/api/plugins/${At}/user/orders/${e}/receipt`,{headers:t,credentials:"same-origin"});return n.ok?{status:n.status,data:await n.json()}:{status:n.status,data:null}}catch{return{status:0,data:null}}}async function ke(e){const t=Tt(),n=Rt(),r={Accept:"application/json"};t?r.Authorization=`Bearer ${t}`:n&&(r["X-Guest-Order-Token"]=n);const o=await be(e,r);if(o.data||o.status!==401||!t)return{status:o.status,info:o.data};const i={Accept:"application/json"};n&&(i["X-Guest-Order-Token"]=n);const s=await be(e,i);return{status:s.status,info:s.data}}async function A(e){return(await ke(e)).info}function E(e){return e?e.receipt_url?!0:e.receipt_type==="cbt_confirmation"&&Array.isArray(e.receipt_fields)&&e.receipt_fields.length>0:!1}function T(e){return e?.receipt_view_label||"영수증 조회"}function xt(e){return e?.receipt_label||"영수증"}function Ce(e){if(e.receipt_url){window.open(e.receipt_url,"kginicis_receipt","width=800,height=600,scrollbars=yes,resizable=yes");return}e.receipt_type==="cbt_confirmation"&&Mt(e)}function Mt(e){const t=window.open("","kginicis_receipt","width=800,height=700,scrollbars=yes,resizable=yes");if(!t)return;const n=e.receipt_title||"KG 이니시스 CBT 결제확인서",r=e.receipt_notice||"",o=(e.receipt_fields??[]).map(i=>`
-
${E(i.label)}
-
${E(i.value)}
+
${S(i.label)}
+
${S(i.value)}
`).join("");t.document.open(),t.document.write(` - ${E(n)} + ${S(n)}