From faa79b0ae1bad51eb6549eb884a8939bb18eb050 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Mon, 13 Jul 2026 14:39:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(tosspayments,pay=5Fkginicis,ecommerce,core?= =?UTF-8?q?):=20=ED=98=84=EA=B8=88=EC=98=81=EC=88=98=EC=A6=9D=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EB=B0=94=EC=9D=B4=EB=8D=94=20=ED=9B=85=20=EC=B6=95=20?= =?UTF-8?q?+=20=ED=86=A0=EC=8A=A4=20=ED=99=98=EB=B6=88=20=EC=A0=95?= =?UTF-8?q?=ED=95=A9=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현금영수증 발급사를 PG 결제사와 독립적으로 선택할 수 있도록 공용 훅 축 (registered_providers/issue/cancel)에 토스·KG 프로바이더를 등록한다. KG 결제로 받으면서 영수증만 토스로 발급하는 구성이 가능해진다. KG 전용 발행 컨트롤러· 라우트·관리자 패널은 이 축으로 이전하며 제거했다 — 이중 발행 경로를 남기지 않기 위해서다. 토스 환불은 네 가지를 바로잡았다. 전액취소를 부분취소로 보내던 문제(R1), 면세금액 미전달로 복합과세 상점의 부가세가 틀어지던 문제(R2), 가상계좌 환불계좌를 싣지 않아 취소가 실패하던 문제(R3), 에스크로 부분취소를 PG 호출 전에 차단하지 않던 문제(E4). 취소·발급 요청에는 멱등키를 부착해 네트워크 재시도가 국세청에 이중 신고되거나 이중 환불되는 것을 막는다. 코어 활동로그 가드가 \Exception 만 잡아 채널 해석 실패(\Error)를 놓치고 있었다. 결제 입금통보 같은 외부 콜백이 로그 문제만으로 500을 반환하던 문제를 \Throwable 로 수정한다. 활동로그는 부수 기록이므로 그것을 유발한 요청까지 죽여서는 안 된다. 토스 API 서비스는 베이스 \Exception 을 직접 던지고 있어 도메인 예외 (TossPaymentsApiException)를 신설했다. KG의 KgInicisApiException 선례와 동형이며 RuntimeException 상속이라 기존 소비자의 catch 는 그대로 동작한다. --- CHANGELOG.md | 1 + .../Traits/ResolvesActivityLogType.php | 10 +- docs/backend/api/README.md | 4 +- .../CHANGELOG.md | 10 + .../backend/ja/messages.php | 6 +- .../language-pack.json | 2 +- .../CHANGELOG.md | 4 +- .../backend/ja/messages.php | 7 + .../frontend/ja.json | 17 + .../language-pack.json | 2 +- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 1 + .../src/Services/OrderCancellationService.php | 6 +- .../Playwright/fixtures/ecommerce-auth.ts | 33 + .../mp06-coupon-and-mileage-timing.spec.ts | 4 +- .../admin/order-status-notification.spec.ts | 0 .../specs/order-option-status-review.spec.ts | 0 .../specs/shop/cash-receipt-lifecycle.spec.ts | 682 ++++++ .../specs/shop/checkout-cash-receipt.spec.ts | 70 +- .../DTO/PromotionsSummaryFlatKeysTest.php | 4 +- .../Seeders/ProductSeederImageTest.php | 4 +- .../EcommerceUserActivityLogListenerTest.php | 49 +- .../Listeners/SearchProductsListenerTest.php | 4 +- .../SeoCategoryCacheListenerTest.php | 4 +- .../Listeners/SeoProductCacheListenerTest.php | 4 +- .../SeoSettingsCacheListenerTest.php | 4 +- .../Unit/Requests/StoreBanksRequestTest.php | 9 +- .../StoreEcommerceSettingsRequestSeoTest.php | 4 +- .../Resources/HasMultiCurrencyPricesTest.php | 28 +- .../Unit/Services/CashReceiptServiceTest.php | 5 + .../Services/CategoryImageServiceTest.php | 27 +- .../EcommerceSettingsMileageTimingTest.php | 4 +- ...mmerceSettingsServiceCurrencyMergeTest.php | 4 +- .../Services/OrderCancellationServiceTest.php | 67 + .../Unit/Services/ProductImageServiceTest.php | 7 +- .../Unit/Services/SearchPresetServiceTest.php | 21 +- .../Unit/Services/SequenceServiceTest.php | 10 +- .../Unit/Upgrades/Upgrade_0_7_0_Test.php | 4 +- .../sirsoft-pay_kginicis/CHANGELOG.md | 17 +- .../_bundled/sirsoft-pay_kginicis/README.md | 4 +- .../sirsoft-pay_kginicis/composer.json | 2 +- .../sirsoft-pay_kginicis/docs/api/README.md | 6 +- .../sirsoft-pay_kginicis/docs/api/cbt.md | 16 +- .../sirsoft-pay_kginicis/docs/api/orders.md | 97 +- .../sirsoft-pay_kginicis/docs/api/payment.md | 785 +++++- .../docs/api/transaction.md | 6 +- .../sirsoft-pay_kginicis/docs/api/vbank.md | 8 +- .../sirsoft-pay_kginicis/lang/en/messages.php | 6 +- .../sirsoft-pay_kginicis/lang/ko/messages.php | 6 +- .../sirsoft-pay_kginicis/package-lock.json | 4 +- .../sirsoft-pay_kginicis/package.json | 2 +- .../_bundled/sirsoft-pay_kginicis/plugin.json | 4 +- .../_bundled/sirsoft-pay_kginicis/plugin.php | 1 + .../extensions/admin_order_payment_query.json | 2121 ++++++++++++----- .../__tests__/mypageOrderShowInjector.test.ts | 111 + .../resources/lang/en.json | 14 - .../resources/lang/ko.json | 14 - .../AdminCashReceiptController.php | 210 -- .../RegisterCashReceiptProviderListener.php | 311 +++ .../src/Services/KgInicisApiService.php | 192 +- .../sirsoft-pay_kginicis/src/routes/api.php | 6 - .../AdminCashReceiptControllerTest.php | 71 - .../Controllers/AdminRoutePermissionTest.php | 24 +- .../ValidatesTimestampFreshnessTest.php | 7 +- ...mmercePaymentMethodsLayoutListenerTest.php | 4 +- ...erDetailPaymentQueryLayoutListenerTest.php | 33 +- ...nOrderDetailTestModeLayoutListenerTest.php | 28 +- ...inOrderListTestBadgeLayoutListenerTest.php | 17 +- ...egisterCashReceiptProviderListenerTest.php | 348 +++ .../BackendMessageKeyResolutionTest.php | 100 + .../MigrateNaverpayBrandButtonKeyTest.php | 4 +- .../sirsoft-tosspayments/CHANGELOG.md | 19 +- .../sirsoft-tosspayments/composer.json | 2 +- .../sirsoft-tosspayments/lang/en/messages.php | 7 + .../sirsoft-tosspayments/lang/ko/messages.php | 7 + .../sirsoft-tosspayments/package-lock.json | 4 +- .../sirsoft-tosspayments/package.json | 2 +- .../_bundled/sirsoft-tosspayments/plugin.json | 2 +- .../_bundled/sirsoft-tosspayments/plugin.php | 18 + .../extensions/admin_order_payment.json | 169 ++ .../resources/extensions/user_order_show.json | 104 + .../resources/lang/en.json | 17 + .../resources/lang/ko.json | 17 + .../Exceptions/TossPaymentsApiException.php | 19 + .../src/Listeners/PaymentRefundListener.php | 192 +- .../RegisterCashReceiptProviderListener.php | 268 +++ ...oreLayoutExtensionsAfterUpdateListener.php | 185 ++ .../src/Services/TossPaymentsApiService.php | 116 +- .../PaymentCallbackControllerTest.php | 11 +- .../Listeners/PaymentRefundListenerTest.php | 457 +++- ...egisterCashReceiptProviderListenerTest.php | 417 ++++ .../Services/TossPaymentsApiServiceTest.php | 79 +- ...ash-receipt-provider-refund-integrity.yaml | 117 + .../InicisIdentityProviderWithConfigTest.php | 12 +- .../Services/InicisCallbackOutcomeTest.php | 8 +- .../ResolvesActivityLogTypeTest.php | 59 +- 95 files changed, 6637 insertions(+), 1372 deletions(-) rename {tests => modules/_bundled/sirsoft-ecommerce/tests}/Playwright/specs/admin/order-status-notification.spec.ts (100%) rename {tests => modules/_bundled/sirsoft-ecommerce/tests}/Playwright/specs/order-option-status-review.spec.ts (100%) create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/cash-receipt-lifecycle.spec.ts delete mode 100644 plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCashReceiptController.php create mode 100644 plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/RegisterCashReceiptProviderListener.php delete mode 100644 plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminCashReceiptControllerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/RegisterCashReceiptProviderListenerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Support/BackendMessageKeyResolutionTest.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/resources/extensions/admin_order_payment.json create mode 100644 plugins/_bundled/sirsoft-tosspayments/resources/extensions/user_order_show.json create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Exceptions/TossPaymentsApiException.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterCashReceiptProviderListener.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/RegisterCashReceiptProviderListenerTest.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/tests/scenarios/cash-receipt-provider-refund-integrity.yaml 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 는 네이티브