diff --git a/CHANGELOG.md b/CHANGELOG.md index cfe56798..d4cf3f1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -244,6 +244,7 @@ - 화면 구성 파일이 목록을 만들어 내는 과정에서 다국어 이름을 골라 쓸 때, 사이트를 어떤 언어로 보고 있든 항상 한국어 이름이 선택되던 문제를 수정했습니다. 이제 보고 있는 언어의 이름이 표시됩니다. 헤더의 배송국가 선택처럼 화면이 처음 열릴 때 목록을 만들어 두는 곳이 여기에 해당합니다. - 메뉴를 눌러 화면을 이동했을 때, 필요한 정보를 여러 갈래로 나눠 받아오는 화면에서 먼저 도착한 정보가 뒤늦게 도착한 정보에 덮여 사라지는 경우가 있었습니다. 그 탓에 목록이 있어야 할 자리가 간헐적으로 비어 보이고 새로고침하면 정상으로 돌아오곤 했습니다. 이제 어느 정보가 먼저 도착하든 모두 반영됩니다. 이커머스 환경설정의 배송설정 탭에서 배송가능 국가 목록이 비어 보이던 것이 여기에 해당합니다. - 이미 설치를 마친 환경의 설정 파일(`.env`)을 새 서버로 복사해, 데이터베이스가 아직 비어 있는 상태에서 설치를 이어가려 할 때 `php artisan migrate`로 테이블을 만들기도 전에 부팅 단계에서 오류가 나며 중단되던 문제를 수정했습니다. 이제 데이터베이스 작업 명령이 실행되는 동안에는 테이블 존재 여부를 실제로 확인하므로, 기존 서버의 설정을 재사용하는 신규 배포에서도 설치가 정상적으로 진행됩니다. (#41 @GyusoonKim 님께서 제보해주셨습니다.) +- 활동 로그 기록에 실패했을 때 그 기록을 유발한 요청까지 함께 실패하던 문제를 수정했습니다. 결제 입금통보 같은 외부 콜백이 활동 로그 문제로 오류를 반환하는 일이 없어집니다. ## [7.0.2] - 2026-07-08 diff --git a/app/ActivityLog/Traits/ResolvesActivityLogType.php b/app/ActivityLog/Traits/ResolvesActivityLogType.php index d0268296..e977b0e0 100644 --- a/app/ActivityLog/Traits/ResolvesActivityLogType.php +++ b/app/ActivityLog/Traits/ResolvesActivityLogType.php @@ -44,8 +44,8 @@ trait ResolvesActivityLogType * properties.extension_origin 에 자동 주입합니다 (loggable 미지정 케이스의 * action 라벨 namespace fallback 용). * - * @param string $action 액션명 (예: 'user.create') - * @param array $context Monolog context 배열 + * @param string $action 액션명 (예: 'user.create') + * @param array $context Monolog context 배열 */ protected function logActivity(string $action, array $context): void { @@ -58,9 +58,13 @@ trait ResolvesActivityLogType $context['properties'] = $properties; } + // 활동 로그는 부수 기록이다 — 실패해도 그것을 유발한 요청(결제 콜백 등)까지 + // 죽여서는 안 된다. 채널 해석 실패는 Exception 이 아니라 Error 로 오므로 + // (\Log::channel() 이 null 을 반환하면 "->info() on null" = \Error) + // Throwable 로 받아야 가드가 실제로 동작한다. try { Log::channel('activity')->info($action, $context); - } catch (\Exception $e) { + } catch (\Throwable $e) { Log::error('Failed to record activity log', [ 'action' => $action, 'error' => $e->getMessage(), diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index 5637b14f..ab1c0aac 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -229,7 +229,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 14 · **엔드포인트 수**: 402 +- **확장 수**: 14 · **엔드포인트 수**: 414 | 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | | --- | --- | --- | --- | @@ -241,7 +241,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `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-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | +| `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 | | `sirsoft-tosspayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-tosspayments/docs/api/README.md) | 2 / 4 | diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md index 77578a65..2075d43a 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.1.0] - 2026-08-06 + +### Added + +- 현금영수증 발급사 이름(`cash_receipt.provider_name`) 일본어 번역 추가. + +### Removed + +- KG이니시스 전용 현금영수증 발행 화면이 제거되면서 함께 쓰이지 않게 된 발행 오류 안내 3종의 번역을 정리했습니다. + ## [1.0.3] - 2026-07-22 ### Added diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php index 40625ce1..4fb37e19 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php @@ -2,9 +2,6 @@ return [ 'errors' => [ - 'cash_receipt_invalid_issue_type' => '発行タイプが正しくありません。', - 'cash_receipt_missing_issue_number' => '識別番号を入力してください。', - 'cash_receipt_already_issued' => 'すでに現金領収書が発行された決済です。', 'cash_receipt_issue_failed' => '現金領収書の発行に失敗しました。', 'order_not_found' => '注文が見つかりません。', 'cbt_failed' => '海外決済の処理に失敗しました。', @@ -39,6 +36,9 @@ return [ 'japan_contact_opening_hours_required' => 'ライブ日本決済画面に表示するお問い合わせ営業時間が必要です。', 'replace_sample_value' => 'ライブモードではサンプル値の代わりに実際の契約情報を入力してください。', ], + 'cash_receipt' => [ + 'provider_name' => 'KGイニシス', + ], 'cbt_cvs' => [ 'simulate_success' => '入金シミュレーションが完了しました。', 'simulate_failed' => '入金シミュレーションに失敗しました。', diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/language-pack.json index bc3476e3..1e5be7a0 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/language-pack.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)", "ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)" }, - "version": "1.0.3", + "version": "1.1.0", "license": "MIT", "scope": "plugin", "target_identifier": "sirsoft-pay_kginicis", diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md index da9ea40c..3e9b3486 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md @@ -4,10 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.0-beta.2] - 2026-06-11 +## [1.0.0] - 2026-07-13 ### Added +- 현금영수증 발급·취소 안내와 오류 메시지 일본어 번역 추가 — 일본어 로케일에서 현금영수증 발급 결과와 실패 사유가 일본어로 표시됩니다. +- 환불 관련 안내(에스크로 부분취소 불가·환불 계좌 누락) 일본어 번역 추가. - 결제 화면 프론트엔드 텍스트 일본어 번역 추가 — 결제 관련 화면이 일본어 로케일에서 자연스럽게 표시됩니다. - 주문서형 결제수단(카드·가상계좌·계좌이체·휴대폰·간편결제 9종)의 이름과 설명 일본어 번역 추가 — 일본어 로케일 주문서에서 결제수단이 일본어로 표시됩니다. - 관리자 설정 화면의 결제 방식·가상계좌·에스크로 항목과 입력값 오류 안내 일본어 번역 추가. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php index 0a14d5c8..33071568 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php @@ -4,9 +4,16 @@ return [ 'refund' => [ 'missing_payment_key' => 'Toss Payments の決済キーが存在しないため、返金処理ができません。', 'default_reason' => '顧客リクエストによるキャンセル', + 'escrow_partial_not_allowed' => 'エスクロー決済は部分キャンセルに対応していません。全額キャンセルのみ可能です。', + 'missing_refund_account' => '仮想口座決済をキャンセルするには、返金を受け取る口座情報(銀行、口座番号、預金者名)が必要です。', ], 'settings_validation' => [ 'vbank_valid_hours_range' => '仮想口座の入金期限は :min~:max時間(最大90日)の間である必要があります。', 'use_escrow_invalid' => 'エスクロー使用設定値が正しくありません。', ], + 'cash_receipt' => [ + 'provider_name' => 'Toss Payments', + 'invalid_order_id' => '現金領収書発行識別子がToss Payments形式(英数字·ハイフン·アンダースコア 6~64文字)に一致していません。', + 'cancel_reason' => '注文金額変更に伴う再発行', + ], ]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/frontend/ja.json index cd6583b2..d2335902 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/frontend/ja.json @@ -73,5 +73,22 @@ "order_not_found": "注文が見つかりません。", "payment_failed": "決済に失敗しました。", "non_krw_method": "この決済方法はウォン(KRW)決済にのみ使用できます。" + }, + "user": { + "payment_type_label": "決済方法", + "escrow_yes": "エスクロー", + "receipt_label": "領収書", + "receipt_view": "領収書を表示", + "cash_receipt_label": "現金領収書", + "cash_receipt_view": "現金領収書を表示" + }, + "admin": { + "payment_panel_title": "Toss Payments決済情報", + "escrow_label": "決済方法", + "escrow_yes": "エスクロー", + "vbank_label": "仮想口座", + "vbank_due_label": "入金期限", + "receipt_label": "領収書", + "receipt_view": "領収書を表示" } } diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json index e09efe20..fa473df9 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 plugin (sirsoft-tosspayments) Japanese language pack (bundled)", "ja": "G7 プラグイン (sirsoft-tosspayments) 日本語 言語パック(バンドル)" }, - "version": "1.0.0-beta.2", + "version": "1.0.0", "license": "MIT", "scope": "plugin", "target_identifier": "sirsoft-tosspayments", diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index cb5180b6..5215c9d8 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -62,6 +62,7 @@ - 과세·면세 금액 합계에 배송비가 포함됩니다. 그동안 배송비는 과세/면세 어느 쪽에도 집계되지 않아 상품 금액만 합산되었습니다. - 현금영수증 발급 용도 값을 소득공제(income) / 지출증빙(expense)으로 통일했습니다. 기존에 다른 값으로 저장된 주문은 모듈 업데이트 시 자동으로 정리됩니다. - 주문 상세 응답에 현금영수증 정보와 결제 영수증 링크가 포함됩니다. +- 결제대행사 환불 연동에 환불 건 정보를 함께 전달합니다. 결제 플러그인이 이 값으로 중복 취소를 방지할 수 있습니다 — 네트워크 재시도로 같은 취소 요청이 두 번 전달되어도 이중 환불되지 않습니다. - 관리자 상품 목록이 상품마다 옵션 전체를 함께 내려주지 않습니다. 목록에는 옵션 개수와 재고 합계만 실리고, 옵션 상세는 행을 펼칠 때 필요한 상품의 것만 한 번에 가져옵니다. 옵션을 많이 쓰는 쇼핑몰일수록 목록을 여는 속도와 전송량이 크게 개선됩니다(상품 100건에 상품당 옵션 20건 기준, 목록 응답 크기 약 94% 감소). 화면에서 보이는 내용과 일괄 변경 적용 범위는 이전과 동일하며, 옵션을 목록 응답에 함께 받으려면 주소 뒤에 `?with_options=1` 을 붙이면 됩니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) - 관리자 주문 목록이 주문마다 주문상품 전체를 함께 내려주지 않습니다. 목록은 대표 상품 1건과 "외 N건" 표기만 사용하므로, 이제 그 1건과 개수만 읽습니다. 한 주문에 상품을 많이 담는 쇼핑몰일수록 목록을 여는 속도와 전송량이 개선됩니다. 화면에 보이는 내용(대표 상품·추가옵션 요약·"외 N건"·부분취소 표시)은 이전과 동일하며, 마이페이지 주문내역은 종전대로 주문상품 전체를 보여줍니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) - 상품 옵션의 표시 순서를 상품 등록/수정 화면과 동일하게 정렬 순서 기준으로 통일했습니다. 이전에는 관리자 상품 목록의 펼친 옵션만 정해진 순서 없이 표시돼 화면마다 순서가 달라 보일 수 있었습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php index c97a56e9..680851c0 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php @@ -986,13 +986,17 @@ class OrderCancellationService $orderCurrency = $order->payment->currency ?: ($order->currency_snapshot['order_currency'] ?? null); $refundLocal = $this->resolveOrderCurrencyRefundAmount($result, $orderCurrency); + // $refund 는 이 환불 시도의 고유 레코드다. PG 리스너가 멱등키(Idempotency-Key)를 조립할 때 + // 사용한다 — 네트워크 재시도로 같은 취소가 두 번 도달해도 PG 가 중복 청구를 차단한다. + // 인자 추가는 하위호환이다: 기존 리스너(5-파라미터)는 초과 인자를 그대로 무시한다. $pgResult = HookManager::applyFilters( 'sirsoft-ecommerce.payment.refund', ['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null], $order, $order->payment, $refundLocal, - $reason + $reason, + $refund ); if (! empty($pgResult['success'])) { diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts index c3e023f5..1b31be82 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts @@ -21,6 +21,16 @@ type EcommerceAuthFixtures = { dashboardToken: string; /** 권한 없는 일반 사용자 토큰 (메뉴 미노출 검증용) */ noPermissionToken: string; + /** 일반 구매자 토큰 (상점 화면 — 장바구니·주문서 등. 관리자 권한 없음) */ + userToken: string; + /** 구매자 토큰 (마이페이지 — 주문조회·취소요청 등. 관리자 권한 없음) */ + customerToken: string; + /** 주문 조회 전용 권한 토큰 (읽기만 — 수정 게이팅 검증용) */ + ordersReadToken: string; + /** 회원 토큰 (마이페이지 주문상세·리뷰 등. 관리자 권한 없음) */ + memberToken: string; + /** 주문 수정 권한 토큰 (입금확인·상태 전이 검증용) */ + ordersUpdateToken: string; /** 마일리지 조회 + 수동 관리 권한 보유 토큰 */ mileageManageToken: string; /** 마일리지 조회 전용 토큰 (수동 지급/차감 게이팅 검증용) */ @@ -29,6 +39,8 @@ type EcommerceAuthFixtures = { productManageToken: string; /** 주문 조회(`orders.read`) 권한 보유 토큰 (주문 상세 표시 검증용) */ ordersReadToken: string; + /** 주문 조회 + 수정 권한 보유 토큰 (입금확인·취소·현금영수증 발급 검증용) */ + orderManageToken: string; /** 배송정책 조회+수정 권한 보유 토큰 (배송정책 목록 표시 검증용) */ shippingPolicyToken: string; /** 일반 쇼핑 사용자 토큰 (관리자 권한 없음 — 유저 화면 검증용) */ @@ -48,6 +60,24 @@ export const test = base.extend({ // 빈 권한 — 인증만 통과, 어떤 모듈 권한도 없음 await use(issueToken()); }, + userToken: async ({}, use) => { + // 상점 이용자 — 관리자 권한 없이 인증만 통과한다 (장바구니/주문서는 회원이면 이용 가능). + await use(issueToken()); + }, + customerToken: async ({}, use) => { + // 마이페이지 이용자 — 자기 주문만 다루므로 관리자 권한이 필요 없다. + await use(issueToken()); + }, + ordersReadToken: async ({}, use) => { + await use(issueToken('sirsoft-ecommerce.orders.read')); + }, + memberToken: async ({}, use) => { + // 회원 — 자기 주문/리뷰만 다루므로 관리자 권한이 필요 없다. + await use(issueToken()); + }, + ordersUpdateToken: async ({}, use) => { + await use(issueToken('sirsoft-ecommerce.orders.read', 'sirsoft-ecommerce.orders.update')); + }, mileageManageToken: async ({}, use) => { await use(issueToken('sirsoft-ecommerce.mileage.read', 'sirsoft-ecommerce.mileage.manage')); }, @@ -60,6 +90,9 @@ export const test = base.extend({ ordersReadToken: async ({}, use) => { await use(issueToken('sirsoft-ecommerce.orders.read')); }, + orderManageToken: async ({}, use) => { + await use(issueToken('sirsoft-ecommerce.orders.read', 'sirsoft-ecommerce.orders.update')); + }, shippingPolicyToken: async ({}, use) => { await use( issueToken( diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mp06-coupon-and-mileage-timing.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mp06-coupon-and-mileage-timing.spec.ts index 36fe89da..9c0d808f 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mp06-coupon-and-mileage-timing.spec.ts +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mp06-coupon-and-mileage-timing.spec.ts @@ -13,8 +13,8 @@ * - 무통장 1회 제한 쿠폰은 주문 생성 즉시 USED 차감되어 입금 전까지 재사용 불가(공개#57). * * Select 렌더/disabled 바인딩은 레이아웃 렌더 테스트(adminEcommerceSettingsOrder.test.tsx)가, - * 차감 시점 분기·쿠폰 검증·선차감/반환은 PHPUnit(OrderCalculation·CouponUse·CouponRestore· - * EcommerceSettingsMileageTiming·MigrateMileageDeductionTiming)가 커버한다. 본 spec 은 브라우저에서 + * 차감 시점 분기·쿠폰 검증·선차감/반환은 PHPUnit(OrderCalculation, CouponUse, CouponRestore, + * EcommerceSettingsMileageTiming, MigrateMileageDeductionTiming)가 커버한다. 본 spec 은 브라우저에서 * 설정 disabled 연동 + 주문서 위반 안내 노출만 왕복 검증한다. * * 활성화 전 사전 작업(data-testid 보강): diff --git a/tests/Playwright/specs/admin/order-status-notification.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/order-status-notification.spec.ts similarity index 100% rename from tests/Playwright/specs/admin/order-status-notification.spec.ts rename to modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/order-status-notification.spec.ts diff --git a/tests/Playwright/specs/order-option-status-review.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/order-option-status-review.spec.ts similarity index 100% rename from tests/Playwright/specs/order-option-status-review.spec.ts rename to modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/order-option-status-review.spec.ts diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/cash-receipt-lifecycle.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/cash-receipt-lifecycle.spec.ts new file mode 100644 index 00000000..87601a15 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/cash-receipt-lifecycle.spec.ts @@ -0,0 +1,682 @@ +/** + * 현금영수증 전주기 E2E (#454 S3 이월 → S5). + * + * checkout-cash-receipt.spec.ts 가 "신청 폼이 렌더·동작하는가" 를 본다면, 이 spec 은 + * 신청 이후의 **발급 생애주기**를 본다: 입금확인 → 자동발급 → 금액변동 → 전액취소·재발급. + * + * 단위 테스트가 green 인 상태에서도 실환경에서만 드러난 결함이 실제로 있었다(§13-3 실측): + * - 취소 API 에 cancelReason 누락 → 토스가 400 거부 (Http::fake 는 본문을 검증하지 않음) + * - 취소 응답의 'c_' 접두 키를 저장 → 영수증이 영원히 "활성" 으로 남아 재발급 미발동 + * 두 결함 모두 브라우저 + 실 PG 경로에서만 재현된다. 그래서 이 spec 은 PG 응답을 모킹하지 않고 + * 관리자 화면의 실제 버튼을 눌러 서버가 PG 를 호출하게 한다. + * + * 발급 결과는 화면 문구가 아니라 서버 상태(주문 상세 API 의 cash_receipt / cash_receipts)로 + * 단언한다 — 화면 문구는 i18n 으로 바뀌지만 발급 회차·금액·상태는 도메인 사실이다. + * + * 전제: 관리자 환경설정에 현금영수증 발급사(cash_receipt_provider)가 설정되어 있고 그 PG 의 + * 테스트 키가 유효해야 한다. 미설정/무효면 발급 자체가 불가하므로 해당 테스트는 skip 한다 + * (실패로 위장하지 않는다 — 환경 미비와 코드 결함을 구분한다). + * + * @scenario cash-receipt-provider-and-refund-integrity + * @effects amount_change_triggers_cancel_then_reissue, + * cancel_delegated_as_full_cancel_without_amount, + * order_id_suffixed_with_issue_sequence, + * tax_free_amount_sent_with_issue, + * issue_response_mapped_to_receipt_key_and_issue_number, + * receipt_type_converted_to_provider_specific_value + */ +import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth'; +import type { Page } from '@playwright/test'; + +const CART_LOCALE = 'ko'; + +const TOGGLE = '#ext_checkout_cash_receipt_toggle'; +const IDENTIFIER = '#ext_checkout_cash_receipt_identifier'; + +type ApiResult = { status: number; body: string }; + +/** 앱이 쓰는 것과 동일한 Bearer 헤더로 API 를 호출한다. */ +async function api( + page: Page, + path: string, + init: { method?: string; body?: unknown } = {} +): Promise { + return page.evaluate( + async ({ path, init, locale }) => { + const res = await fetch(path, { + method: init.method ?? 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Accept-Language': locale, + Authorization: `Bearer ${localStorage.getItem('auth_token')}`, + }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + }); + return { status: res.status, body: await res.text() }; + }, + { path, init, locale: CART_LOCALE } + ); +} + +/** + * 주문번호로 관리자용 주문 id 를 얻는다. + * + * 관리자 상세 라우트는 id 바인딩이고, 주문 생성 결과로 손에 쥐는 건 주문번호다. + * 유저 주문 상세는 주문번호로 조회되며 id 를 함께 내려준다. + */ +async function resolveOrderId(page: Page, orderNumber: string): Promise { + const res = await api(page, `/api/modules/sirsoft-ecommerce/user/orders/${orderNumber}`); + expect(res.status, `주문(${orderNumber}) 조회 실패: ${res.body}`).toBe(200); + const id = JSON.parse(res.body)?.data?.id as number; + expect(id, '주문 id 를 얻지 못했습니다.').toBeTruthy(); + return id; +} + +/** 주문 상세(관리자)를 읽어 현금영수증 상태를 돌려준다. */ +async function readReceiptState(page: Page, orderId: number) { + const res = await api(page, `/api/modules/sirsoft-ecommerce/admin/orders/${orderId}`); + expect(res.status, `주문 상세 조회 실패: ${res.body}`).toBe(200); + const data = JSON.parse(res.body)?.data ?? {}; + return { + total: Number(data.total_amount ?? 0), + active: data.cash_receipt ?? null, + history: (data.cash_receipts ?? []) as Array>, + }; +} + +/** + * 관리자 주문 상세에서 입금확인을 처리한다 (자동발급 리스너의 발화 지점). + * + * 이미 주문 상세 화면에 있어야 한다. + */ +async function confirmDeposit(page: Page, amount: number): Promise { + await page.getByRole('button', { name: '입금확인' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.waitFor({ state: 'visible' }); + await dialog.locator('input[type=number]').fill(String(amount)); + await dialog.locator('input[type=checkbox]').check(); + await dialog.getByRole('button', { name: '입금확인' }).click(); + + // 입금확인 요청은 그 안에서 자동발급 리스너가 **동기적으로** PG 왕복을 수행한다. + // 실측상 응답까지 30초를 넘기는 경우가 있어(주문 생성 → 발급 완료 시각차 ~40초), + // 30초로 잡으면 "모달이 안 닫힘" 으로 위장된 타임아웃이 난다 — 서버는 정상 처리 중이다. + await expect(dialog).toBeHidden({ timeout: 120_000 }); +} + +/** + * 자진발급(미신청 주문을 국세청 지정번호로 자동발급) 설정을 읽는다. + * + * 이 설정이 켜져 있으면 "신청하지 않은 주문" 도 입금완료 시 자동발급된다 — + * 사후 발급(유저가 나중에 직접 발급)의 전제인 "미발급 주문" 이 만들어지지 않는다. + */ +async function selfIssueEnabled(page: Page): Promise { + const res = await api(page, '/api/modules/sirsoft-ecommerce/settings/payment'); + if (res.status !== 200) return false; + return Boolean(JSON.parse(res.body)?.data?.order_settings?.cash_receipt_self_issue); +} + +/** 발급사가 설정되어 있는지 — 미설정이면 발급 경로 자체가 존재하지 않는다. */ +async function cashReceiptProvider(page: Page): Promise { + const res = await api(page, '/api/modules/sirsoft-ecommerce/settings/payment'); + if (res.status !== 200) return null; + const provider = JSON.parse(res.body)?.data?.order_settings?.cash_receipt_provider; + return typeof provider === 'string' && provider !== '' ? provider : null; +} + +/** + * 무통장 + 현금영수증 신청 주문을 체크아웃 화면에서 실제로 만든다. + * + * 주문 생성은 검증 대상(발급 생애주기)의 전제이지 검증 대상이 아니므로 카트/임시주문은 + * 사용자가 밟는 API 경로로 만들되, 현금영수증 신청 자체는 화면 폼으로 입력한다. + */ +async function placeDbankOrderWithCashReceipt( + page: Page, + identifier: string | null +): Promise { + const seeded = await page.evaluate(async ({ locale }) => { + const headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Accept-Language': locale, + Authorization: `Bearer ${localStorage.getItem('auth_token')}`, + }; + + const list = await fetch('/api/modules/sirsoft-ecommerce/products?per_page=30', { + headers, + }).then((r) => r.json()); + const candidates = (list?.data?.data ?? []) as Array<{ id: number }>; + + await fetch('/api/modules/sirsoft-ecommerce/cart/all', { method: 'DELETE', headers }); + + for (const candidate of candidates) { + const detail = await fetch( + `/api/modules/sirsoft-ecommerce/products/${candidate.id}`, + { headers } + ).then((r) => r.json()); + const options = (detail?.data?.options ?? []) as Array<{ + option_values_localized?: Record; + stock_quantity?: number; + }>; + + // 부분취소를 만들려면 수량 2 가 필요하다. 재고가 모자란 옵션을 고르면 + // 주문 생성이 422(재고 부족)로 실패하고, 그 실패가 "현금영수증 결함" 으로 오해된다. + const usable = options.find((o) => (o.stock_quantity ?? 0) >= 2); + if (!usable) continue; + + const added = await fetch('/api/modules/sirsoft-ecommerce/cart', { + method: 'POST', + headers, + body: JSON.stringify({ + product_id: candidate.id, + items: [{ option_values: usable.option_values_localized ?? {}, quantity: 2 }], + }), + }); + if (!added.ok) continue; + + const cart = await fetch('/api/modules/sirsoft-ecommerce/cart', { headers }).then((r) => + r.json() + ); + const itemIds = (cart?.data?.items ?? []).map((i: { id: number }) => i.id); + const checkout = await fetch('/api/modules/sirsoft-ecommerce/checkout', { + method: 'POST', + headers, + body: JSON.stringify({ item_ids: itemIds }), + }); + if (checkout.ok) return { ok: true, body: '' }; + } + return { ok: false, body: '담을 수 있는 상품이 없습니다.' }; + }, { locale: CART_LOCALE }); + + expect(seeded.ok, seeded.body).toBe(true); + + await page.goto('/shop/checkout'); + await page.waitForLoadState('domcontentloaded'); + + // 무통장 결제수단 선택 + await page.getByTestId('checkout-payment-method-dbank').click(); + + // 현금영수증 신청 (소득공제 + 휴대폰번호 — 기본 선택 그대로 사용). + // identifier 가 null 이면 신청하지 않는다 — "미신청 주문" 은 사후 발급의 전제다. + if (identifier !== null) { + await page.locator(TOGGLE).click(); + await page.locator(IDENTIFIER).fill(identifier); + } + + // 나머지 필수값은 앱 상태로 채운다 — 이 spec 의 검증 대상이 아닌 입력이다. + // - 입금할 계좌(_local.selectedDbank): 아이콘 기반 커스텀 컨트롤이라 클릭 대상이 불안정하다. + // 관리자 환경설정에 등록된 계좌를 그대로 골라 앱 핸들러와 같은 상태를 만든다. + // - 주문자 연락처: 회원 프로필에 없으면 빈 값이라 서버가 422 로 거절한다. + // - 배송지 주소: 주소검색이 외부 스크립트(다음 우편번호) 팝업이다. + const filled = await page.evaluate(async () => { + const core = ( + window as unknown as { + G7Core: { + state: { + getLocal(): Record; + setLocal(v: Record): void; + }; + }; + } + ).G7Core; + + const settings = await fetch('/api/modules/sirsoft-ecommerce/settings/payment', { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${localStorage.getItem('auth_token')}`, + }, + }).then((r) => r.json()); + + const accounts = (settings?.data?.order_settings?.bank_accounts ?? []) as Array< + Record + >; + if (accounts.length === 0) return { ok: false, reason: '등록된 입금 계좌가 없습니다.' }; + + const local = core.state.getLocal(); + const shipping = (local.shipping ?? {}) as Record; + const orderer = (local.orderer ?? {}) as Record; + + core.state.setLocal({ + selectedDbank: accounts[0], + orderer: { ...orderer, phone: '010-1234-5678' }, + shipping: { + ...shipping, + recipient_name: '테스트수령인', + recipient_phone: '010-1234-5678', + zipcode: '06236', + address: '서울특별시 강남구 테헤란로 152', + address_detail: '10층', + }, + }); + return { ok: true, reason: '' }; + }); + + expect(filled.ok, filled.reason).toBe(true); + + const submit = page.getByRole('button', { name: /결제하기/ }); + await expect(submit).toBeEnabled({ timeout: 10_000 }); + + // 주문 생성 실패를 "화면이 안 넘어감" 으로 뭉뚱그리면 원인을 알 수 없다. + // 서버 응답을 붙잡아 실패 시 본문을 그대로 드러낸다. + const orderPost = page.waitForResponse( + (r) => r.url().includes('/user/orders') && r.request().method() === 'POST', + { timeout: 30_000 } + ); + await submit.click(); + + const posted = await orderPost; + expect( + posted.status(), + `주문 생성 실패 (${posted.status()}): ${await posted.text()}` + ).toBeLessThan(300); + + await page.waitForURL(/\/shop\/orders\/.+\/complete/, { timeout: 30_000 }); + const orderNumber = page.url().match(/orders\/([^/]+)\/complete/)?.[1]; + expect(orderNumber, '주문번호를 URL 에서 얻지 못했습니다.').toBeTruthy(); + return orderNumber as string; +} + +test.describe('현금영수증 전주기 (신청 → 입금확인 → 자동발급 → 금액변동 재발급)', () => { + // 각 테스트가 실 PG(토스) 왕복을 여러 번 수행한다. 병렬로 돌리면 동시 발급 요청이 겹쳐 + // 응답 지연이 누적되고, 그 지연이 "모달이 안 닫힘" 같은 UI 결함으로 오인된다. + test.describe.configure({ mode: 'serial' }); + + // 한 테스트가 주문생성 → 입금확인 → PG 발급 → 부분취소 → PG 취소 → PG 재발급을 모두 밟는다. + // PG 왕복이 여러 번이라 기본 30초로는 부족하다 (초과 시 모달 대기 실패로 위장된다). + test.setTimeout(180_000); + + test('무통장 신청 → 입금확인 → 자동발급 → 영수증 링크 노출', async ({ + page, + orderManageToken, + }) => { + await authenticatePage(page, orderManageToken); + await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE); + await page.goto('/shop'); + await page.waitForLoadState('domcontentloaded'); + + const provider = await cashReceiptProvider(page); + test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경 — 발급 경로 자체가 없음'); + + const orderNumber = await placeDbankOrderWithCashReceipt(page, '01012341234'); + + // 주문 조회 → 입금 전에는 발급 이력이 없어야 한다. + const orderId = await resolveOrderId(page, orderNumber); + + const before = await readReceiptState(page, orderId); + expect(before.active, '입금 전에는 발급된 영수증이 없어야 한다').toBeNull(); + + // 관리자 화면에서 입금확인 — 자동발급 리스너가 여기서 발화한다. + await page.goto(`/admin/ecommerce/orders/${orderId}`); + await confirmDeposit(page, before.total); + + // 자동발급 결과를 서버 상태로 단언한다. + await expect + .poll(async () => (await readReceiptState(page, orderId)).active?.issue_status, { + timeout: 30_000, + }) + .toBe('COMPLETED'); + + const after = await readReceiptState(page, orderId); + expect(after.active?.provider).toBe(provider); + expect(Number(after.active?.amount)).toBe(before.total); + expect(after.active?.issue_number, '발급번호가 없다').toBeTruthy(); + expect(String(after.active?.receipt_url ?? ''), '영수증 URL 이 없다').toMatch(/^https?:\/\//); + + // 식별번호는 마스킹되어야 한다 (원문 노출 금지). + expect(String(after.active?.identifier_masked ?? '')).not.toContain('01012341234'); + + // 화면에도 링크가 노출된다. + await page.reload(); + await expect(page.locator('a[href^="http"]', { hasText: /영수증/ }).first()).toBeVisible({ + timeout: 15_000, + }); + }); + + test('자동발급 → 부분환불 → 전액취소 후 재발급 (발급 회차 증가)', async ({ + page, + orderManageToken, + }) => { + await authenticatePage(page, orderManageToken); + await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE); + await page.goto('/shop'); + await page.waitForLoadState('domcontentloaded'); + + const provider = await cashReceiptProvider(page); + test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경'); + + const orderNumber = await placeDbankOrderWithCashReceipt(page, '01055556666'); + const orderId = await resolveOrderId(page, orderNumber); + + const before = await readReceiptState(page, orderId); + + // 입금확인 → 자동발급 + await page.goto(`/admin/ecommerce/orders/${orderId}`); + await confirmDeposit(page, before.total); + + await expect + .poll(async () => (await readReceiptState(page, orderId)).active?.issue_status, { + timeout: 30_000, + }) + .toBe('COMPLETED'); + + const issued = await readReceiptState(page, orderId); + const firstReceiptId = issued.active?.id; + const firstNumber = issued.active?.issue_number; + expect(firstReceiptId, '최초 발급 영수증을 식별하지 못했다').toBeTruthy(); + + // 부분취소 — 주문상품 1건(수량 2) 중 1 개만 취소해 주문 금액을 낮춘다. + await page.reload(); + await page.waitForLoadState('networkidle'); + + // 주문상품 행이 렌더될 때까지 기다린다. 주문 데이터는 비동기로 로드되므로 + // 바로 체크박스를 누르면 헤더(전체선택)만 있는 상태를 건드리게 된다. + const rowCheckbox = page.locator('input[type=checkbox]').nth(1); + await rowCheckbox.waitFor({ state: 'visible', timeout: 20_000 }); + await rowCheckbox.click(); + + // 화면 문구("N개 선택됨")가 아니라 핸들러가 실제로 읽는 앱 상태를 단언한다. + // buildOrderDetailBulkConfirmData 는 _local.selectedProducts 를 읽으며, + // 이 값이 비어 있으면 취소할 항목이 없다고 판단해 모달을 열지 않는다. + await expect + .poll( + async () => + page.evaluate(() => { + const g = ( + window as unknown as { + G7Core?: { state?: { getLocal?(): Record } }; + } + ).G7Core; + return ((g?.state?.getLocal?.()?.selectedProducts ?? []) as unknown[]).length; + }), + { timeout: 10_000 } + ) + .toBeGreaterThan(0); + + // 주문상태 Select 는 네이티브