fix(ecommerce,core): 현금영수증 UI 보완 + 하네스 결함 6건 + 확장점 룰 사각 제거

현금영수증/환불계좌 기능(S3)의 감사 보완과, 그 과정에서 발견한 하네스 결함을
같은 세션에서 일괄 처리한다. 지시에 따라 무관 산출물(namuwiki 문서 초안 포함)도
단일 커밋으로 묶는다.

- 현금영수증 발급 이력 접기/펼치기(W-3) + 결제행별 독립 토글 + aria 상태
- vendor-bundle manifest 해시 재생성 — composer.json version bump 미반영분 정합
- 시나리오 매니페스트 거짓 경로 정정(A/B) + 미작성 부채 :test-files 정직 표기(C)
- layout-* 룰의 resources/extensions 검사 사각 제거 + 확장점 전용 root 스키마 분기
- validate-test-scenario.cjs: 맵형 test_files 크래시 + fallback 파서 covers 오검출 수정
- tosspayments 확장점 setState target 규약 위반 수정
This commit is contained in:
HeuJung
2026-08-06 12:36:31 +09:00
parent 58560eefb1
commit 42eb21320b
106 changed files with 6294 additions and 177 deletions
+47
View File
@@ -419,6 +419,53 @@ v1.17.0 이전: modals 내부의 extension_point는 처리되지 않았음
| `prepend` | default **앞에** 추가 | `[NEW] [default...]` |
| `replace` | default **완전 교체** | `[NEW]` (default 제거) |
### 확장 병합 칸 (주입 폼의 값을 템플릿 API 요청에 실어 보내기)
슬롯에 주입한 폼이 입력값을 서버로 보내려면, 그 값이 **템플릿이 소유한 apiCall 의 `params.body`** 에 도달해야 한다. 그런데 확장에는 body 에 키를 추가할 수단이 없다.
- 결제 버튼처럼 `id` 가 없는 노드는 overlay `target_id` 로 잡을 수 없다
- `inject_props` 는 컴포넌트 props 병합 전용이라 `actions[].params.body` 에 닿지 않는다 (`_merge` 는 shallow)
그래서 템플릿이 **확장 병합 칸**을 하나 열어 둔다. 규약은 이렇다.
**템플릿 쪽** — body 를 통짜 표현식으로 만들고 말미에 확장 칸을 spread 한다.
```json
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/user/orders",
"params": {
"body": "{{ ({ temp_order_id: _local.tempOrderId, payment_method: _computed.selectedPaymentMethod, ...(_local.checkoutExtraPayload ?? {}) }) }}"
}
}
```
**확장 쪽** — 자기 필드를 그 칸에 setState 한다.
```json
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"checkoutExtraPayload.cash_receipt_requested": "{{$event.target.checked}}"
}
}
```
지켜야 할 것:
- 확장은 **자기 도메인 키만** 쓴다. spread 가 뒤에 오므로 템플릿의 기존 키를 덮어쓸 수 있다
- 폼을 접거나 신청을 해제하면 칸을 `{}` 로 비운다. 남겨 두면 서버가 신청으로 오인한다
- 템플릿이 소유한 필드(예: 환불계좌)는 칸을 경유하지 않고 body 에 직접 기재한다
- body 를 통짜 표현식으로 전환할 때는 **전환 전후 산출값이 동일한지** 회귀 테스트로 고정한다 (`DataBindingEngine.evaluateExpression` 으로 실제 평가)
현재 열려 있는 칸:
| 칸 이름 | 위치 | 소비처 |
|---------|------|--------|
| `_local.checkoutExtraPayload` | `sirsoft-basic` 주문서 (`_checkout_summary.json`) | 주문 생성 `POST /user/orders` |
---
## 템플릿 오버라이드
@@ -10,6 +10,10 @@
- 현금영수증 관련 문구(`cash_receipt.*` — 발급 용도, 식별번호 종류, 발급/취소 이력 상태, 배송비 과세 방식, 오류 안내) 일본어 번역 추가 — 현금영수증 발급 화면과 이커머스 환경설정이 일본어 로케일에서 자연스럽게 표시됩니다.
- 현금영수증 발급 요청의 입력 항목명·검증 안내·처리 결과 문구(`cash_receipt.attributes.*`, `cash_receipt.validation.*`, `cash_receipt.messages.*`)와 발급 불가 사유 안내(`cash_receipt.errors.*`) 일본어 번역 추가 — 잘못된 식별번호를 입력했을 때의 안내와 발급·취소 결과 메시지가 일본어 로케일에서 자연스럽게 표시됩니다.
- 주문서의 현금영수증 신청 폼 문구(`checkout.cash_receipt.*` — 신청 여부, 발급 용도, 발급 수단, 입력 안내) 일본어 번역 추가 — 무통장입금으로 주문할 때 현금영수증을 신청하는 화면이 일본어 로케일에서 자연스럽게 표시됩니다.
- 주문 상세의 현금영수증 카드 문구(발급 상태 안내, 발급·발급취소·재발급 버튼, 발급 이력 표) 일본어 번역 추가 — 구매자와 관리자가 보는 현금영수증 영역이 일본어 로케일에서 자연스럽게 표시됩니다.
- 환불 계좌 입력 안내와 검증 문구(`validation.order.refund_bank_*`) 일본어 번역 추가 — 주문서와 관리자 취소 화면의 환불 계좌 입력란이 일본어 로케일에서 자연스럽게 표시됩니다.
- 현금영수증 자진발급 설정의 국세청 의무발행 업종 안내 링크 문구(`order_settings.cash_receipt.self_issue_mandatory_link`) 일본어 번역 추가 — 자진발급을 켤지 판단할 때 참고하는 국세청 안내 링크가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.8] - 2026-08-01
@@ -58,4 +58,9 @@ return [
'reissued' => '現金領収書が再発行されました。',
'status_retrieved' => '現金領収書情報を照会しました。',
],
'result_status' => [
'in_progress' => '処理中',
'completed' => '完了',
'failed' => '失敗',
],
];
@@ -836,6 +836,13 @@ return [
'guest_lookup_password_min' => '注文照会パスワードは8文字以上である必要があります。',
'guest_lookup_password_confirmed' => '注文照会パスワードが一致しません。',
'guest_lookup_password_confirmation_required' => '注文照会パスワード確認を入力してください。',
'cash_receipt_type_required' => '現金領収証の発行目的を選択してください。',
'cash_receipt_type_invalid' => '現金領収証の発行目的が正しくありません。',
'cash_receipt_identifier_type_required' => '現金領収証の発行方法を選択してください。',
'cash_receipt_identifier_type_invalid' => '現金領収証の発行方法が正しくありません。',
'cash_receipt_identifier_required' => '現金領収証の発行に使用する番号を入力してください。',
'refund_bank_required_with' => '返金口座は銀行·口座番号·預金者をすべて入力する必要があります。',
'refund_bank_required_for_vbank' => '入金が完了した仮想口座注文は返金口座が必要です。',
],
'order_bulk' => [
'ids_required' => '変更する注文を選択してください。',
@@ -165,7 +165,8 @@
"seoStats": "SEO統計",
"settings": "設定",
"shipping_policies": "配送ポリシー一覧",
"templates": "テンプレート一覧"
"templates": "テンプレート一覧",
"paymentSettings": "決済環境設定"
},
"action": {
"request_pg_payment": {
@@ -195,5 +196,19 @@
"label": "配送国",
"hint": "基本配送国です。登録後、マイ情報で変更できます。"
}
},
"checkout": {
"cash_receipt": {
"request": "現金領収証申請",
"purpose": "用途",
"purpose_income": "所得控除用",
"purpose_expense": "支出証明用",
"identifier_phone": "携帯電話番号",
"identifier_card": "現金領収証カード番号",
"identifier_business": "事業者登録番号",
"identifier_placeholder": "- なしで数字のみ入力",
"purpose_help": "所得控除用は年末調整に、支出証明用は事業者の仕入税額控除に使用されます。",
"immutable_help": "発行後は用途を変更することはできません。"
}
}
}
@@ -419,7 +419,13 @@
"close_btn": "閉じる",
"estimate_failed": "返金予想金額の計算に失敗しました。",
"cancel_success": "注文がキャンセルされました。",
"cancel_failed": "注文のキャンセルに失敗しました。"
"cancel_failed": "注文のキャンセルに失敗しました。",
"refund_bank_label": "返金口座",
"refund_bank_select": "銀行を選択",
"refund_bank_account_placeholder": "口座番号 (ハイフンなしで入力)",
"refund_bank_holder_placeholder": "口座名義人",
"refund_bank_required_hint": "入金が完了した仮想口座注文は返金口座が必要です。",
"refund_bank_partial_notice": "3つのうちいずれかを入力した場合は、残りも入力する必要があります。"
},
"reset_guest_password": {
"title": "非会員閲覧パスワード再設定",
@@ -450,6 +456,38 @@
"mark_order_complete": "注文を決済完了として処理",
"mark_order_complete_hint": "チェックすると入金記録とともに注文ステータスを決済完了に変更します(ポイント積立·在庫減少·通知送信含む)。チェックしないと入金内訳のみ記録します。"
}
},
"cash_receipt": {
"title": "現金領収書",
"issue_title": "現金領収書発行",
"status_issued": "発行完了",
"awaiting_deposit": "入金が確認されたら発行できます。",
"purpose": "用途",
"purpose_income": "所得控除用",
"purpose_expense": "支出証拠用",
"identifier_type": "発行手段",
"identifier": "識別番号",
"identifier_phone": "携帯電話番号",
"identifier_card": "現金領収書カード番号",
"identifier_business": "事業者登録番号",
"identifier_placeholder": "ハイフンなしで数字のみ入力",
"immutable_help": "発行後は用途を変更することはできません。",
"amount": "金額",
"tax_free_amount": "非課税金額",
"issue_number": "発行番号",
"issued_at": "発行日時",
"issue": "現金領収書発行",
"cancel": "キャンセル",
"cancel_issue": "発行取消",
"reissue": "手動再発行",
"view_receipt": "領収書を表示",
"history": "発行履歴 ({{count}}件)",
"issue_success": "現金領収書が発行されました。",
"issue_failed": "現金領収書の発行に失敗しました。",
"cancel_success": "現金領収書の発行がキャンセルされました。",
"cancel_failed": "現金領収書の発行キャンセルに失敗しました。",
"reissue_failed": "再発行失敗",
"reissue_success": "現金領収書が再発行されました。"
}
},
"preset": {
@@ -224,6 +224,23 @@
"restore_on_cancel": "注文キャンセル時の在庫復旧",
"restore_on_cancel_description": "注文がキャンセルされると、差し引かれた在庫を自動的に復旧します。",
"restore_on_cancel_hint": "返品·交換処理時にも適用されます。"
},
"cash_receipt": {
"title": "現金領収証",
"description": "現金領収証の発行プロバイダーと配送料の課税方式を設定します。",
"provider_label": "現金領収証プロバイダー",
"provider_none": "(未使用)",
"provider_not_installed": "決済プラグインを有効化すると、発行プロバイダーを選択できます。",
"provider_help": "決済に使用するPGとは別に選択できます。",
"shipping_fee_tax_label": "配送料の課税方式",
"shipping_fee_tax_proportional": "按分 (デフォルト)",
"shipping_fee_tax_taxable": "全額課税",
"shipping_fee_tax_follow_main_item": "主要な財貨基準",
"shipping_fee_tax_help": "按分は、課税·非課税商品の金額比率で配送料を分割します。",
"self_issue_label": "自進発行の使用",
"self_issue_help": "購入者が申請していない無通帳入金建も自動で発行します (所得控除用専用)。",
"self_issue_mandatory_help": "現金領収証の義務発行業種は、購入者が申請しなくても発行義務があります。該当業種であればオンにしてください。",
"self_issue_mandatory_link": "国税庁の義務発行業種案内"
}
},
"claim": {
@@ -12,5 +12,29 @@
"delivered": "配送完了",
"cancelled": "キャンセル済み",
"refunded": "返金済み"
},
"cash_receipt": {
"title": "現金領収証",
"issue_title": "現金領収証の発行",
"purpose": "用途",
"purpose_income": "所得控除用",
"purpose_expense": "支出証明用",
"identifier_type": "発行手段",
"identifier": "識別番号",
"identifier_phone": "電話番号",
"identifier_card": "現金領収証カード番号",
"identifier_business": "事業者登録番号",
"identifier_placeholder": "ハイフンなしで数字のみを入力",
"immutable_help": "発行後は用途を変更することはできません。",
"awaiting_deposit": "入金が確認されると発行されます。",
"none": "発行された現金領収証がありません。",
"issue": "現金領収証の発行",
"cancel": "キャンセル",
"issued_at": "発行日時",
"view_receipt": "領収証を表示",
"issue_success": "現金領収証が発行されました。",
"issue_failed": "現金領収証の発行に失敗しました。",
"reissue_failed": "再発行失敗",
"reissue_failed_help": "返金に伴う現金領収証の再発行に失敗しました。カスタマーサポートにお問い合わせください。"
}
}
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.1.0] - 2026-08-06
### Added
- 주문서의 환불 계좌 입력란 문구(`shop.checkout.refund_bank_*` — 안내 제목, 은행 선택, 계좌번호·예금주 입력 안내, 부분 입력 주의) 일본어 번역 추가 — 무통장입금·가상계좌로 주문할 때 환불받을 계좌를 남기는 영역이 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.2] - 2026-07-22
### Added
@@ -465,7 +465,12 @@
"guest_token_issue_failed": "非会員照会認証準備に失敗しました。注文照会時に携帯電話とパスワードで再度ご確認ください。",
"coupon_already_used": "他の商品に既に適用されています",
"coupon_not_applied": "一部のクーポンを適用できないため除外されました。最小注文金額·使用回数·重複使用の条件を確認してください。",
"shipping_country": "配送国家"
"shipping_country": "配送国家",
"refund_bank_title": "返金口座 (注文キャンセル時に使用、選択)",
"refund_bank_select": "銀行を選択",
"refund_bank_account_placeholder": "口座番号 (ハイフンなしで入力)",
"refund_bank_holder_placeholder": "口座名義人",
"refund_bank_partial_notice": "3つのいずれかを入力した場合、残りも入力する必要があります。"
},
"order_complete": {
"page_title": "注文完了",
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
},
"version": "1.0.2",
"version": "1.1.0",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-basic",
@@ -29,6 +29,18 @@
- 현금영수증 식별번호(휴대폰번호·사업자등록번호 등)는 재발급에 쓸 수 있도록 암호화해 보관하고 구매확정 시점에 폐기합니다. 이력에는 뒤 4자리를 제외한 마스킹 값만 남으며, 발급 업체가 보내온 응답에 섞여 오는 개인정보도 가려서 저장합니다. 주민등록번호는 수집하지 않습니다.
- 결제 영수증(카드 매출전표 등)과 현금영수증을 각각 따로 조회할 수 있습니다. 두 영수증은 서로 다른 값으로 보관되어 한쪽이 다른 쪽을 덮어쓰지 않습니다.
#### 신청·발급 화면
- 주문서의 무통장입금 결제 영역에서 현금영수증을 신청할 수 있습니다. 용도(소득공제/지출증빙)에 따라 고를 수 있는 발급 수단이 바뀌며, 그 용도에 쓸 수 없는 수단이 선택되어 있으면 자동으로 되돌립니다. 발급 업체를 설정하지 않은 상점에서는 신청란이 나타나지 않습니다.
- 구매자 주문 상세에 현금영수증 카드가 표시됩니다. 입금 전에는 안내만, 입금 후 미발급이면 발급 버튼, 발급 후에는 발급일시·용도·식별번호와 영수증 보기 링크가 나타납니다. 환불에 따른 재발급이 실패한 경우에는 안내 문구가 표시됩니다.
- 관리자 주문 상세에 현금영수증 카드가 표시됩니다. 발급·발급취소·수동 재발급을 할 수 있고 발급·취소 이력을 함께 볼 수 있습니다. 재발급이 실패한 주문에는 경고와 함께 다시 발급하는 버튼이 나타납니다.
- 이커머스 환경설정 주문설정 탭에서 현금영수증 발급 업체, 배송비 과세 방식, 자진발급 사용 여부를 설정할 수 있습니다.
#### 환불 계좌
- 주문서의 무통장입금·가상계좌 결제 영역에서 환불받을 계좌(은행·계좌번호·예금주)를 미리 입력할 수 있습니다. 입력하지 않아도 주문할 수 있지만, 세 칸 중 하나라도 입력하면 나머지도 입력해야 합니다.
- 관리자 주문 취소 화면에서 환불 계좌를 입력하거나 수정할 수 있습니다. 주문할 때 입력한 계좌가 있으면 미리 채워집니다. 입금이 완료된 가상계좌 주문은 환불에 계좌가 반드시 필요하므로 입력하지 않으면 취소할 수 없습니다. 무통장입금은 관리자가 직접 이체할 때 참고하도록 선택 입력이며, 카드·간편결제는 원래 결제수단으로 환불되므로 계좌란이 나타나지 않습니다.
#### 금액·과세
- 주문에 현금성 금액이 기록됩니다. 무통장입금 주문에서 마일리지를 사용한 경우, 실제로 계좌에 입금된 금액만 현금영수증 발급 대상이 됩니다. 주문을 일부 취소하면 남은 실입금액 기준으로 다시 계산됩니다.
@@ -157,6 +169,7 @@
- 관리자 목록에서 기록이 아주 많아 총 건수를 끝까지 세지 못하면 "1페이지뿐" 으로 표시되어 뒤쪽 페이지를 볼 수 없던 문제를 수정했습니다. 주문·상품·마일리지·쿠폰·쿠폰 발급 이력·상품 리뷰·배송 정책·활동 이력 목록이 해당하며, 마지막 페이지로 바로 뛰는 버튼만 감춰지고 이전·다음 이동은 그대로 동작합니다.
- 쿠폰 사용조건의 상품 검색에서 결과가 많을 때 목록 끝까지 스크롤해도 다음 묶음이 더 불러와지지 않던 문제를 수정했습니다.
- 관리자가 주문상품을 일괄 구매확정할 때와 구매자가 직접 구매확정할 때, 구매확정 시점이 최초 확정 시각으로 동일하게 보존됩니다 — 이미 확정된 주문을 다시 확정 처리해도 시점이 덮어써지지 않습니다.
- 구매자 주문 상세의 결제 정보 영역에 결제 플러그인이 제공하는 공급가액·부가세·영수증 링크·에스크로 구매확정 버튼이 표시되지 않던 문제를 수정했습니다.
## [1.0.4] - 2026-07-16
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-ecommerce",
"version": "1.0.5",
"version": "1.1.0",
"components": {
"basic": [],
"composite": [],
File diff suppressed because one or more lines are too long
@@ -42,7 +42,19 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
**응답 예시**
<!-- 실측 제외: http-404 — 응답 예시는 사람이 작성하세요. -->
<!-- @probed -->
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "Checkout has been deleted.",
"data": null
}
```
**에러 응답**
@@ -208,7 +220,7 @@ Content-Type: application/json
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
<!-- 실측 제외: http-404 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
@@ -423,7 +423,7 @@ _단건 응답: `data` 객체의 필드._
| payment_currency | string | `JPY` | 결제 통화 (유저가 선택·결제한 통화, base_currency 와 다르면 병기 표시) |
| is_cross_currency | boolean | `false` | cross currency 여부 |
| order_status | string | `payment_complete` | 주문상태 (OrderStatusEnum) |
| order_status_label | string | `결제완료` | `order_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) |
| order_status_label | string | `Payment Complete` | `order_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) |
| order_status_variant | string | `info` | `order_status` 값의 표시 변형 키 (UI 배지 색상/스타일) |
| is_partially_cancelled | boolean | `false` | partially cancelled 여부 |
| order_device | string | `pc` | 주문 디바이스 (pc/mobile/app) |
@@ -511,8 +511,10 @@ _단건 응답: `data` 객체의 필드._
| options | array | `[{"id":1263,"option_status":"payment_complete","option_st…` | 주문 옵션(품목) 목록 (OrderOptionResource — 상품·옵션·수량·옵션상태·금액) |
| shipping_address | object | `{"id":319,"address_type":"shipping","orderer_name":"박대수",…` | 배송지 상세 (OrderAddressResource — 주문자/수령인/국내·해외 주소) |
| billing_address | null | `null` | 청구지 상세 (OrderAddressResource, 미분리 시 null) |
| payment | object | `{"id":334,"payment_status":"paid","payment_status_label":…` | 대표 결제 정보 (OrderPaymentResource — 결제수단·결제상태·금액) |
| payments | array | `[{"id":334,"payment_status":"paid","payment_status_label"…` | 결제 이력 목록 (OrderPaymentResource 배열 — 다회 결제/부분결제 포함) |
| payment | object | `{"id":1,"payment_status":"paid","payment_status_label":"P…` | 대표 결제 정보 (OrderPaymentResource — 결제수단·결제상태·금액) |
| payments | array | `[{"id":1,"payment_status":"paid","payment_status_label":"…` | 결제 이력 목록 (OrderPaymentResource 배열 — 다회 결제/부분결제 포함) |
| cash_receipt | null | `null` | 현재 유효한 현금영수증 (CashReceiptResource — 취소되지 않은 발급 건, 없으면 null) |
| cash_receipts | array | `[]` | 현금영수증 발급·취소 이력 전체 (CashReceiptResource 배열 — 취소된 건 포함) |
| shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) |
| cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) |
| promotions_applied_snapshot | object | `{"coupon_issue_ids":[7330],"item_coupons":[],"discount_co…` | 적용된 프로모션 스냅샷 (재계산용) |
@@ -653,6 +655,9 @@ Content-Type: application/json
| items | body | array | 아니오 | min 1 | 처리 대상 항목 배열 |
| cancel_pg | body | boolean | 아니오 | — | PG 결제 취소 동반 여부 (미지정 시 기본 true — 실제 PG 취소 수행) |
| refund_priority | body | string | 아니오 | `pg_first`, `points_first` | 환불 배분 우선순위 (pg_first PG 우선 / points_first 포인트 우선, 기본 pg_first) |
| refund_bank.bank_code | body | string | 아니오 | max 10 | 환불 계좌 은행코드. 가상계좌 + 입금완료 건은 필수(주문 시 입력된 계좌가 있으면 생략 가능). 세 필드는 전부 입력하거나 전부 비워야 함 |
| refund_bank.account_number | body | string | 아니오 | max 50 | 환불 계좌번호 |
| refund_bank.holder | body | string | 아니오 | max 50 | 환불 계좌 예금주 |
**요청 예시**
@@ -1641,6 +1646,13 @@ HTTP/1.1 200
| shipping_memo | body | string | 아니오 | max 500 | 배송 요청사항 메모 |
| depositor_name | body | string | 아니오 | max 50 | depositor 이름 (식별자) |
| save_shipping_address | body | boolean | 아니오 | — | 회원 주소록에 이번 배송지 저장 여부 (회원 주문 한정) |
| cash_receipt_requested | body | boolean | 아니오 | — | 현금영수증 신청 여부 (true 면 아래 3개 필드가 필수) |
| cash_receipt_type | body | string | 아니오 | — | 발급 용도 (`income` 소득공제 / `expense` 지출증빙) |
| cash_receipt_identifier_type | body | string | 아니오 | — | 발급 수단 (`phone` 휴대폰번호 / `card` 현금영수증카드 / `business` 사업자등록번호 — business 는 지출증빙 전용) |
| cash_receipt_identifier | body | string | 아니오 | max 30 | 발급 식별번호 (하이픈 없는 숫자. 사업자등록번호는 체크섬 검증. 마스킹 저장 + 원본은 암호화 보관) |
| refund_bank.bank_code | body | string | 아니오 | max 10 | 환불 계좌 은행코드 (세 필드는 전부 입력하거나 전부 비워야 함) |
| refund_bank.account_number | body | string | 아니오 | max 50 | 환불 계좌번호 |
| refund_bank.holder | body | string | 아니오 | max 50 | 환불 계좌 예금주 |
| guest_lookup_password | body | string | 예 | min 8, max 255 | 비회원 주문 조회 비밀번호 (비회원만 필수, 8자 이상 · 해시로 저장) |
| guest_lookup_password_confirmation | body | string | 예 | — | 조회 비밀번호 확인 (guest_lookup_password 와 일치해야 함) |
@@ -1661,6 +1673,10 @@ Content-Type: application/json
"shipping_memo": "예시값",
"depositor_name": "예시 이름",
"save_shipping_address": true,
"cash_receipt_requested": true,
"cash_receipt_type": "예시값",
"cash_receipt_identifier_type": "example-key",
"cash_receipt_identifier": "example-key",
"guest_lookup_password": "Password123!",
"guest_lookup_password_confirmation": "Password123!"
}
@@ -42,9 +42,9 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| basic_info | object | `{"shop_name":"111","route_path":"shop","no_route":false,"…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
| language_currency | object | `{"default_currency":"JPY","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) |
| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) |
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) |
| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) |
| review_settings | object | `{"write_deadline_days":90,"max_images":5,"max_image_size_…` | 리뷰 정책 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) |
@@ -53,6 +53,7 @@ _단건 응답: `data` 객체의 필드._
| mileage | object | `{"enabled":false,"default_earn_rate":1,"earn_trigger":"co…` | 마일리지 설정 (사용 여부·기본 적립률·적립 트리거·통화별 규칙·소멸/소멸 알림·실제 활성 알림 채널 포함) |
| claim | object | `{"refund_reasons":[{"id":1,"type":"refund","code":"order_…` | 클레임 설정 (DB 관리 대상인 환불 사유 목록: 코드·다국어명·귀책 유형·노출/활성 여부) |
| available_pg_providers | array | `[{"id":"kginicis","name_key":"sirsoft-pay_kginicis::provi…` | 설치된 PG 플러그인이 훅으로 등록한 PG 제공자 목록 (id·name_key·지원 결제수단) |
| available_cash_receipt_providers | array | `[]` | 설치된 플러그인이 훅으로 등록한 현금영수증 발급 제공자 목록 (id·name_key — 미등록 시 빈 배열이며 신청 폼이 노출되지 않음) |
| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
| _meta | object | `{"limits":{"auto_cancel_days_min":1,"auto_cancel_days_max…` | <!-- TODO: 설명 --> |
@@ -481,9 +482,9 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| basic_info | object | `{"shop_name":"111","route_path":"shop","no_route":false,"…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
| language_currency | object | `{"default_currency":"JPY","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) |
| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) |
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) |
| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) |
| review_settings | object | `{"write_deadline_days":90,"max_images":5,"max_image_size_…` | 리뷰 정책 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) |
@@ -492,6 +493,7 @@ _단건 응답: `data` 객체의 필드._
| mileage | object | `{"enabled":false,"default_earn_rate":1,"earn_trigger":"co…` | 마일리지 설정 (사용 여부·기본 적립률·적립 트리거·통화별 규칙·소멸/소멸 알림·실제 활성 알림 채널 포함) |
| claim | object | `{"refund_reasons":[{"id":1,"type":"refund","code":"order_…` | 클레임 설정 (DB 관리 대상인 환불 사유 목록: 코드·다국어명·귀책 유형·노출/활성 여부) |
| available_pg_providers | array | `[{"id":"kginicis","name_key":"sirsoft-pay_kginicis::provi…` | 설치된 PG 플러그인이 훅으로 등록한 PG 제공자 목록 (id·name_key·지원 결제수단) |
| available_cash_receipt_providers | array | `[]` | 설치된 플러그인이 훅으로 등록한 현금영수증 발급 제공자 목록 (id·name_key — 미등록 시 빈 배열이며 신청 폼이 노출되지 않음) |
**응답 예시**
@@ -896,9 +898,9 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| basic_info | object | `{"shop_name":"111","route_path":"shop","no_route":false,"…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
| language_currency | object | `{"default_currency":"JPY","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) |
| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) |
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) |
| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) |
| review_settings | object | `{"write_deadline_days":90,"max_images":5,"max_image_size_…` | 리뷰 정책 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) |
@@ -1301,7 +1303,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 체크아웃용 배송 설정 (기본 국가·배송 가능 국가·무료배송·배송유형 등) |
| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 체크아웃용 주문/결제 설정 (기본 PG·활성 결제수단·무통장 계좌 등) |
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 체크아웃용 주문/결제 설정 (기본 PG·활성 결제수단·무통장 계좌 등) |
**응답 예시**
@@ -1434,7 +1436,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 공개 가능한 결제 설정 (활성 결제수단·무통장 은행명 매핑 포함, 민감 정보 제외) |
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 공개 가능한 결제 설정 (활성 결제수단·무통장 은행명 매핑 포함, 민감 정보 제외) |
**응답 예시**
@@ -0,0 +1,269 @@
{
"_comment": "체크아웃 현금영수증 신청 폼 (W-1 / #454). 템플릿 sirsoft-basic 의 _checkout_payment.json 이 dbank 블록 내부 말단에 연 슬롯에 주입한다. 슬롯이 무통장 블록 안이므로 결제수단 게이트는 불필요하고, 프로바이더 미설정 시에만 전체를 숨긴다. 발급 프로바이더는 PG 플러그인이 담당하지만 신청 폼은 PG 비종속이라 모듈이 소유한다 — 플러그인이 주입하면 두 PG 동시 활성 시 폼이 2개 렌더된다.",
"extension_point": "shop_checkout_cash_receipt_slot",
"mode": "append",
"priority": 100,
"components": [
{
"id": "ext_checkout_cash_receipt",
"comment": "프로바이더 미설정이면 슬롯 전체 미렌더 (M2 — DOM 카운트 0)",
"type": "basic",
"name": "Div",
"if": "{{!!paymentSettings?.data?.order_settings?.cash_receipt_provider}}",
"props": { "className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-600" },
"responsive": {
"portable": {
"props": { "className": "mt-3 pt-3 border-t border-gray-200 dark:border-gray-600" }
}
},
"children": [
{
"comment": "신청 체크박스 — 해제 시 하위 3필드를 if 로 숨기고 확장 칸을 비운다",
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center gap-2" },
"children": [
{
"id": "ext_checkout_cash_receipt_toggle",
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "cash_receipt_requested",
"checked": "{{_local.cashReceiptRequested ?? false}}",
"className": "w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
},
"actions": [
{
"comment": "체크 시 기본값(소득공제 × 휴대폰번호)으로 확장 칸을 시드하고, 해제 시 확장 칸을 비워 주문 payload 에서 제거한다.",
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"cashReceiptRequested": "{{$event.target.checked}}",
"cashReceiptType": "{{$event.target.checked ? (_local.cashReceiptType ?? 'income') : null}}",
"cashReceiptIdentifierType": "{{$event.target.checked ? (_local.cashReceiptIdentifierType ?? 'phone') : null}}",
"cashReceiptIdentifier": "{{$event.target.checked ? (_local.cashReceiptIdentifier ?? '') : null}}",
"checkoutExtraPayload": "{{$event.target.checked ? { cash_receipt_requested: true, cash_receipt_type: (_local.cashReceiptType ?? 'income'), cash_receipt_identifier_type: (_local.cashReceiptIdentifierType ?? 'phone'), cash_receipt_identifier: (_local.cashReceiptIdentifier ?? '') } : {}}}"
}
}
]
},
{
"type": "basic",
"name": "Label",
"props": { "className": "text-sm font-medium text-gray-700 dark:text-gray-300" },
"text": "$t:sirsoft-ecommerce.checkout.cash_receipt.request"
}
]
},
{
"id": "ext_checkout_cash_receipt_fields",
"comment": "신청 시에만 렌더 (M4 — 클릭 전후 필드 수 0 → 3)",
"type": "basic",
"name": "Div",
"if": "{{!!_local.cashReceiptRequested}}",
"props": { "className": "mt-3 space-y-3" },
"children": [
{
"comment": "용도 — 라디오 2종 (가로 → portable 세로 스택)",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"text": "$t:sirsoft-ecommerce.checkout.cash_receipt.purpose"
},
{
"id": "ext_checkout_cash_receipt_purpose",
"type": "basic",
"name": "Div",
"props": { "className": "flex flex-row gap-4" },
"responsive": {
"portable": {
"props": { "className": "flex flex-col gap-2" }
}
},
"children": [
{
"comment": "소득공제용",
"type": "basic",
"name": "Label",
"props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" },
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "radio",
"name": "cash_receipt_type",
"value": "income",
"checked": "{{(_local.cashReceiptType ?? 'income') === 'income'}}",
"className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
},
"actions": [
{
"comment": "소득공제로 전환 — 사업자등록번호는 소득공제에 쓸 수 없으므로 발급수단이 business 면 phone 으로 리셋 (M5)",
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"cashReceiptType": "income",
"cashReceiptIdentifierType": "{{_local.cashReceiptIdentifierType === 'business' ? 'phone' : (_local.cashReceiptIdentifierType ?? 'phone')}}",
"cashReceiptIdentifier": "{{_local.cashReceiptIdentifierType === 'business' ? '' : (_local.cashReceiptIdentifier ?? '')}}",
"checkoutExtraPayload": "{{ { cash_receipt_requested: true, cash_receipt_type: 'income', cash_receipt_identifier_type: (_local.cashReceiptIdentifierType === 'business' ? 'phone' : (_local.cashReceiptIdentifierType ?? 'phone')), cash_receipt_identifier: (_local.cashReceiptIdentifierType === 'business' ? '' : (_local.cashReceiptIdentifier ?? '')) } }}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-ecommerce.checkout.cash_receipt.purpose_income"
}
]
},
{
"comment": "지출증빙용",
"type": "basic",
"name": "Label",
"props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" },
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "radio",
"name": "cash_receipt_type",
"value": "expense",
"checked": "{{_local.cashReceiptType === 'expense'}}",
"className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
},
"actions": [
{
"comment": "지출증빙으로 전환 — 발급수단 3종이 모두 유효하므로 현재 선택을 유지한다 (D10: 휴대폰도 지출증빙 가능)",
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"cashReceiptType": "expense",
"checkoutExtraPayload": "{{ { cash_receipt_requested: true, cash_receipt_type: 'expense', cash_receipt_identifier_type: (_local.cashReceiptIdentifierType ?? 'phone'), cash_receipt_identifier: (_local.cashReceiptIdentifier ?? '') } }}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-ecommerce.checkout.cash_receipt.purpose_expense"
}
]
}
]
}
]
},
{
"comment": "발급수단 / 번호 — 2열 → portable 1열",
"id": "ext_checkout_cash_receipt_identifier_row",
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-2 gap-3" },
"responsive": {
"portable": {
"props": { "className": "grid grid-cols-1 gap-3" }
}
},
"children": [
{
"id": "ext_checkout_cash_receipt_identifier_type",
"comment": "발급수단 options — 소득공제는 2종, 지출증빙은 사업자등록번호 포함 3종 (D10). computed 대신 표현식으로 {value,label} 변환 (valueKey/labelKey prop 금지).",
"type": "basic",
"name": "Select",
"props": {
"name": "cash_receipt_identifier_type",
"value": "{{_local.cashReceiptIdentifierType ?? 'phone'}}",
"options": "{{(_local.cashReceiptType === 'expense' ? [{ value: 'business', label: $t('sirsoft-ecommerce.checkout.cash_receipt.identifier_business') }] : []).concat([{ value: 'phone', label: $t('sirsoft-ecommerce.checkout.cash_receipt.identifier_phone') }, { value: 'card', label: $t('sirsoft-ecommerce.checkout.cash_receipt.identifier_card') }])}}",
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"responsive": {
"portable": {
"props": {
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
}
}
},
"actions": [
{
"comment": "발급수단 변경 시 번호를 비운다 — 휴대폰번호를 사업자등록번호 칸에 남겨두면 검증에서 422 가 난다.",
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"cashReceiptIdentifierType": "{{$event.target.value}}",
"cashReceiptIdentifier": "",
"checkoutExtraPayload": "{{ { cash_receipt_requested: true, cash_receipt_type: (_local.cashReceiptType ?? 'income'), cash_receipt_identifier_type: $event.target.value, cash_receipt_identifier: '' } }}"
}
}
]
},
{
"id": "ext_checkout_cash_receipt_identifier",
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "cash_receipt_identifier",
"value": "{{_local.cashReceiptIdentifier ?? ''}}",
"placeholder": "$t:sirsoft-ecommerce.checkout.cash_receipt.identifier_placeholder",
"className": "{{_local.errors?.cash_receipt_identifier ? 'w-full px-4 py-2 border-2 border-red-500 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500' : 'w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500'}}"
},
"responsive": {
"portable": {
"props": {
"className": "{{_local.errors?.cash_receipt_identifier ? 'w-full px-3 py-2 border-2 border-red-500 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 text-sm' : 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm'}}"
}
}
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"cashReceiptIdentifier": "{{$event.target.value}}",
"checkoutExtraPayload": "{{ { cash_receipt_requested: true, cash_receipt_type: (_local.cashReceiptType ?? 'income'), cash_receipt_identifier_type: (_local.cashReceiptIdentifierType ?? 'phone'), cash_receipt_identifier: $event.target.value } }}"
}
}
]
}
]
},
{
"comment": "서버 검증 오류 인라인 노출 (M6 — 사업자번호 체크섬 오류 등)",
"type": "basic",
"name": "Span",
"if": "{{!!_local.errors?.cash_receipt_identifier}}",
"props": { "className": "block text-red-500 text-xs" },
"text": "{{_local.errors?.cash_receipt_identifier?.[0] ?? ''}}"
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-ecommerce.checkout.cash_receipt.purpose_help"
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-ecommerce.checkout.cash_receipt.immutable_help"
}
]
}
]
}
]
}
@@ -0,0 +1,470 @@
{
"_comment": "유저 주문상세 현금영수증 카드 (W-2 / #454). 템플릿 sirsoft-basic 의 partials/mypage/orders/_payment.json 말단 슬롯에 주입한다. 상태머신 5종: ①무통장 아님/프로바이더 미설정→미렌더 ②입금전→안내 ③입금완료+미발급→발급 버튼 ④발급완료→링크 ⑤재발급 실패→경고(유저에겐 수동 재발급 버튼을 주지 않는다 — 관리자 전용). 발급 POST 는 로드된 주문의 실제 id(order.data.id)를 쓴다.",
"extension_point": "mypage_order_cash_receipt_slot",
"mode": "append",
"priority": 100,
"modals": [
{
"id": "ext_cash_receipt_issue_modal",
"comment": "발급 모달 — 상태 ③에서 열린다. 모달은 별도 컨텍스트라 부모 _local 을 참조할 수 없으므로 데이터소스(order)와 _global 만 쓴다.",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-ecommerce.order.cash_receipt.issue_title",
"size": "small"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "space-y-4" },
"children": [
{
"comment": "용도",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"text": "$t:sirsoft-ecommerce.order.cash_receipt.purpose"
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex flex-row gap-4" },
"responsive": {
"portable": { "props": { "className": "flex flex-col gap-2" } }
},
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" },
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "radio",
"name": "receipt_type",
"value": "income",
"checked": "{{(_global.cashReceiptForm?.receipt_type ?? 'income') === 'income'}}",
"className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
},
"actions": [
{
"comment": "소득공제 전환 시 사업자등록번호 선택을 phone 으로 리셋 (소득공제에 사용 불가)",
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"cashReceiptForm.receipt_type": "income",
"cashReceiptForm.identifier_type": "{{_global.cashReceiptForm?.identifier_type === 'business' ? 'phone' : (_global.cashReceiptForm?.identifier_type ?? 'phone')}}",
"cashReceiptForm.identifier": "{{_global.cashReceiptForm?.identifier_type === 'business' ? '' : (_global.cashReceiptForm?.identifier ?? '')}}"
}
}
]
},
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-ecommerce.order.cash_receipt.purpose_income" }
]
},
{
"type": "basic",
"name": "Label",
"props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" },
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "radio",
"name": "receipt_type",
"value": "expense",
"checked": "{{_global.cashReceiptForm?.receipt_type === 'expense'}}",
"className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": { "target": "global", "cashReceiptForm.receipt_type": "expense" }
}
]
},
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-ecommerce.order.cash_receipt.purpose_expense" }
]
}
]
}
]
},
{
"comment": "발급수단 — 지출증빙일 때만 사업자등록번호 추가 (D10)",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"text": "$t:sirsoft-ecommerce.order.cash_receipt.identifier_type"
},
{
"type": "basic",
"name": "Select",
"props": {
"name": "identifier_type",
"value": "{{_global.cashReceiptForm?.identifier_type ?? 'phone'}}",
"options": "{{(_global.cashReceiptForm?.receipt_type === 'expense' ? [{ value: 'business', label: $t('sirsoft-ecommerce.order.cash_receipt.identifier_business') }] : []).concat([{ value: 'phone', label: $t('sirsoft-ecommerce.order.cash_receipt.identifier_phone') }, { value: 'card', label: $t('sirsoft-ecommerce.order.cash_receipt.identifier_card') }])}}",
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"actions": [
{
"comment": "발급수단 변경 시 번호를 비운다 (형식이 달라 검증 실패를 유발)",
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"cashReceiptForm.identifier_type": "{{$event.target.value}}",
"cashReceiptForm.identifier": ""
}
}
]
}
]
},
{
"comment": "번호",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"text": "$t:sirsoft-ecommerce.order.cash_receipt.identifier"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "identifier",
"value": "{{_global.cashReceiptForm?.identifier ?? ''}}",
"placeholder": "$t:sirsoft-ecommerce.order.cash_receipt.identifier_placeholder",
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": { "target": "global", "cashReceiptForm.identifier": "{{$event.target.value}}" }
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{!!_global.cashReceiptForm?.error}}",
"props": { "className": "block text-red-500 text-xs mt-1" },
"text": "{{_global.cashReceiptForm?.error ?? ''}}"
}
]
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-ecommerce.order.cash_receipt.immutable_help"
},
{
"comment": "액션 — 데스크톱 가로 우측정렬, portable 세로 full-width",
"type": "basic",
"name": "Div",
"props": { "className": "flex flex-row justify-end gap-2 pt-2" },
"responsive": {
"portable": { "props": { "className": "flex flex-col gap-2 w-full pt-2" } }
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
},
"text": "$t:sirsoft-ecommerce.order.cash_receipt.cancel",
"actions": [{ "type": "click", "handler": "closeModal" }]
},
{
"id": "ext_cash_receipt_issue_submit",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{!(_global.cashReceiptForm?.identifier ?? '')}}"
},
"text": "$t:sirsoft-ecommerce.order.cash_receipt.issue",
"actions": [
{
"comment": "회원은 user/orders/{id}, 비회원은 guest/orders/{orderNumber} + X-Guest-Order-Token. 주문 응답의 실제 id 를 쓴다(라우트 파라미터가 주문번호일 수 있으므로).",
"type": "click",
"handler": "apiCall",
"target": "{{_global.currentUser?.uuid ? ('/api/modules/sirsoft-ecommerce/user/orders/' + order.data.id + '/cash-receipt') : ('/api/modules/sirsoft-ecommerce/guest/orders/' + order.data.order_number + '/cash-receipt')}}",
"auth_mode": "optional",
"params": {
"method": "POST",
"headers": { "X-Guest-Order-Token": "{{_global.guestOrderToken ?? ''}}" },
"body": {
"receipt_type": "{{_global.cashReceiptForm?.receipt_type ?? 'income'}}",
"identifier_type": "{{_global.cashReceiptForm?.identifier_type ?? 'phone'}}",
"identifier": "{{_global.cashReceiptForm?.identifier ?? ''}}"
}
},
"onSuccess": [
{ "handler": "setState", "params": { "target": "global", "cashReceiptForm": null } },
{ "handler": "closeModal" },
{ "handler": "refetchDataSource", "params": { "dataSourceId": "order" } },
{ "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-ecommerce.order.cash_receipt.issue_success" } }
],
"onError": [
{
"comment": "서버 검증 메시지를 그대로 노출한다 (사업자번호 체크섬·용도×수단 조합 등)",
"handler": "setState",
"params": {
"target": "global",
"cashReceiptForm.error": "{{error.errors?.identifier?.[0] ?? error.message ?? $t('sirsoft-ecommerce.order.cash_receipt.issue_failed')}}"
}
}
]
}
]
}
]
}
]
}
]
}
],
"components": [
{
"id": "ext_mypage_cash_receipt_card",
"comment": "상태 ① — 무통장이 아니거나 프로바이더 미설정이면 카드 자체를 렌더하지 않는다",
"type": "basic",
"name": "Div",
"if": "{{order.data.payment?.payment_method === 'dbank' && !!order.data.payment?.cash_receipt_provider}}",
"props": { "className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-700" },
"responsive": {
"portable": { "props": { "className": "mt-3 pt-3 border-t border-gray-200 dark:border-gray-700" } }
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center gap-2 mb-2" },
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "receipt", "size": "sm", "className": "text-gray-700 dark:text-gray-300" }
},
{
"type": "basic",
"name": "H4",
"props": { "className": "text-sm font-semibold text-gray-900 dark:text-white" },
"text": "$t:sirsoft-ecommerce.order.cash_receipt.title"
}
]
},
{
"id": "ext_mypage_cash_receipt_awaiting",
"comment": "상태 ② 입금 전 — 발급 불가 안내 + 신청내역(신청했을 때만). 입금 전 상태는 두 가지다: 무통장 주문은 ready 로 생성되고 가상계좌는 waiting_deposit 이다 (PaymentStatusEnum::isAwaitingDeposit() 이 둘 다 true). waiting_deposit 만 검사하면 무통장 주문 전부에서 카드 본문이 사라진다.",
"type": "basic",
"name": "Div",
"if": "{{['ready', 'waiting_deposit'].includes(order.data.payment?.payment_status)}}",
"props": { "className": "space-y-1" },
"children": [
{
"type": "basic",
"name": "P",
"props": { "className": "text-sm text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-ecommerce.order.cash_receipt.awaiting_deposit"
},
{
"type": "basic",
"name": "P",
"if": "{{!!order.data.payment?.is_cash_receipt_requested}}",
"props": { "className": "text-sm text-gray-700 dark:text-gray-300" },
"text": "{{order.data.payment?.cash_receipt_type_label ?? ''}} · {{order.data.payment?.cash_receipt_identifier ?? ''}}"
}
]
},
{
"id": "ext_mypage_cash_receipt_issuable",
"comment": "상태 ③ 입금완료 + 미발급 — 발급 버튼. 재발급 실패 이력이 있으면 상태 ⑤(경고)가 담당하므로 여기서 제외한다(두 상태는 배타적이어야 한다).",
"type": "basic",
"name": "Div",
"if": "{{order.data.payment?.payment_status === 'paid' && !order.data.cash_receipt && String((order.data.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() !== 'failed'}}",
"props": { "className": "flex flex-row items-center justify-between gap-2" },
"responsive": {
"portable": { "props": { "className": "flex flex-col items-start gap-2 w-full" } }
},
"children": [
{
"type": "basic",
"name": "P",
"props": { "className": "text-sm text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-ecommerce.order.cash_receipt.none"
},
{
"id": "ext_mypage_cash_receipt_issue_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
},
"responsive": {
"portable": {
"props": {
"type": "button",
"className": "w-full px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
}
}
},
"text": "$t:sirsoft-ecommerce.order.cash_receipt.issue",
"actions": [
{
"comment": "모달을 열기 전에 신청내역으로 폼을 시드한다 (미신청이면 기본값)",
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"cashReceiptForm": "{{ { receipt_type: (order.data.payment?.cash_receipt_type ?? 'income'), identifier_type: (order.data.payment?.cash_receipt_identifier_type ?? 'phone'), identifier: '', error: null } }}"
}
},
{ "handler": "openModal", "target": "ext_cash_receipt_issue_modal" }
]
}
}
]
}
]
},
{
"id": "ext_mypage_cash_receipt_issued",
"comment": "상태 ④ 발급완료 — 발급일시/용도/식별번호 + 영수증 링크",
"type": "basic",
"name": "Div",
"if": "{{!!order.data.cash_receipt}}",
"props": { "className": "space-y-1" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center justify-between" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-ecommerce.order.cash_receipt.issued_at" },
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-900 dark:text-white" }, "text": "{{order.data.cash_receipt?.issued_at_formatted ?? ''}}" }
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center justify-between" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-ecommerce.order.cash_receipt.purpose" },
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-900 dark:text-white" }, "text": "{{order.data.cash_receipt?.receipt_type_label ?? ''}}" }
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center justify-between" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-ecommerce.order.cash_receipt.identifier" },
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-900 dark:text-white" }, "text": "{{order.data.cash_receipt?.identifier_masked ?? ''}}" }
]
},
{
"comment": "영수증 링크 — 이력 테이블 소유(payment.pg_receipt_url 은 카드 매출전표라 별개)",
"type": "basic",
"name": "Div",
"if": "{{!!order.data.cash_receipt?.receipt_url}}",
"props": { "className": "flex flex-row justify-end pt-2" },
"responsive": {
"portable": { "props": { "className": "flex flex-col w-full pt-2" } }
},
"children": [
{
"id": "ext_mypage_cash_receipt_link",
"type": "basic",
"name": "A",
"props": {
"href": "{{order.data.cash_receipt?.receipt_url}}",
"target": "_blank",
"rel": "noopener noreferrer",
"className": "inline-flex items-center justify-center gap-1 px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
},
"responsive": {
"portable": {
"props": {
"href": "{{order.data.cash_receipt?.receipt_url}}",
"target": "_blank",
"rel": "noopener noreferrer",
"className": "inline-flex w-full items-center justify-center gap-1 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
}
}
},
"text": "$t:sirsoft-ecommerce.order.cash_receipt.view_receipt"
}
]
}
]
},
{
"id": "ext_mypage_cash_receipt_failed",
"comment": "상태 ⑤ 취소 성공 + 재발급 실패 — 유저에게는 경고만 노출한다(수동 재발급은 관리자 전용). issue_status backing value 는 대문자(FAILED)라 대소문자 무관 비교한다.",
"type": "basic",
"name": "Div",
"if": "{{!order.data.cash_receipt && (order.data.cash_receipts ?? []).length > 0 && String((order.data.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() === 'failed'}}",
"props": { "className": "rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 space-y-1" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center gap-2" },
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "triangle-exclamation", "size": "sm", "className": "text-amber-600 dark:text-amber-400" }
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-sm font-medium text-amber-800 dark:text-amber-300" },
"text": "$t:sirsoft-ecommerce.order.cash_receipt.reissue_failed"
}
]
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-amber-700 dark:text-amber-400" },
"text": "$t:sirsoft-ecommerce.order.cash_receipt.reissue_failed_help"
}
]
}
]
}
]
}
@@ -157,4 +157,45 @@ describe('executeCancelOrderHandler — 항상 items 전송 (백엔드 FULL 승
expect(body.cancel_pg).toBe(false);
expect(body.refund_priority).toBe('points_first');
});
describe('환불 계좌 (#454 S3)', () => {
beforeEach(() => {
mockOrderDataSource = {
data: { options: [{ id: 100, quantity: 2, option_status: 'payment_complete' }] },
};
mockLocalState.cancelItems = [{ id: 100, quantity: 2, cancel_quantity: 1 }];
});
it('미입력이면 refund_bank 키 자체를 보내지 않는다 (주문 시 입력된 계좌 보존)', async () => {
const body = await runCancel();
expect(body.refund_bank).toBeUndefined();
});
it('3필드를 모두 입력하면 refund_bank 로 전송된다', async () => {
mockLocalState.refundBankCode = '004';
mockLocalState.refundBankAccount = '110-123-456789';
mockLocalState.refundBankHolder = '홍길동';
const body = await runCancel();
expect(body.refund_bank).toEqual({
bank_code: '004',
account_number: '110-123-456789',
holder: '홍길동',
});
});
it('부분 입력도 그대로 전송한다 — 거부 판정은 서버(CancelOrderRequest)의 책임이다', async () => {
mockLocalState.refundBankAccount = '110-123-456789';
const body = await runCancel();
expect(body.refund_bank).toEqual({
bank_code: '',
account_number: '110-123-456789',
holder: '',
});
});
});
});
@@ -133,8 +133,19 @@ describe('주문설정 탭 구조 검증 (_tab_order_settings.json)', () => {
expect(tab.if).toContain('order_settings');
});
it('8개 카드 섹션을 포함해야 한다 (기본 PG / 결제수단 / 계좌 / 자동취소 / 취소가능상태 / 확정가능상태 / 장바구니 / 재고)', () => {
expect(tab.children).toHaveLength(8);
it('카드 섹션이 정해진 순서로 배치된다', () => {
// 개수가 아니라 id 를 고정한다 — 카드가 추가·제거되면 어느 카드인지 바로 드러난다.
expect(tab.children.map((c: any) => c.id)).toEqual([
'default_pg_card',
'cash_receipt_card',
'payment_methods_card',
'bank_accounts_card',
'auto_cancel_card',
'cancellable_statuses_card',
'confirmable_statuses_card',
'cart_expiry_card',
'stock_management_card',
]);
});
});
@@ -0,0 +1,215 @@
/**
* @file cashReceiptResponsiveRender.test.tsx
* @description 체크아웃 현금영수증 폼의 반응형 오버라이드 실렌더 검증 (#454 S3, F5)
*
* cashReceiptUi.test.tsx 는 node 환경(렌더 없음)이라 JSON 구조만 대조한다.
* 그래서 "responsive.portable 이 선언되어 있다" 까지만 알 수 있고,
* "좁은 화면에서 실제로 세로 스택으로 렌더된다" 는 확인하지 못한다.
*
* 본 파일은 DynamicRenderer 를 실제로 렌더해 그 간극을 메운다:
* - DynamicRenderer 는 useResponsive().width 를 읽어 responsiveManager.getMatchingKey 로
* 오버라이드 키를 고른다(DynamicRenderer.tsx:1413, 1445). 그 width 를 mock 으로 주입한다.
* - portable 은 0~1023px 단일 오버라이드다(§6-0). 1024px 이상에서는 base 가 그대로 남아야 한다.
*
* 회귀 방지 대상: 누군가 responsive.portable 을 지우고 Tailwind `md:` 로 되돌리면
* 위지윅 편집기의 디바이스 전환(overrideWidth)이 먹지 않는다 — 그때 이 테스트가 깨진다.
*
* 주의: 이 docblock 에 vitest 환경 지시자 토큰을 문자열로 적지 말 것.
* vitest 는 파일 상단 주석을 정규식으로 훑어 환경을 결정하므로, 산문 속 언급도
* 그대로 적용되어 jsdom 대신 node 로 실행된다(그러면 window 부재로 수집 단계에서 죽는다).
*
* @scenario actor=guest, change_mode=manual
*
* @effects portable_override_applies_below_1024_on_render,
* portable_override_absent_at_1024_on_render,
* new_ui_has_no_tailwind_breakpoint
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
import extensionJson from '../../../extensions/checkout_cash_receipt.json';
/** 현재 테스트가 주입할 화면 너비 (아래 mock 이 읽는다) */
let mockWidth = 1280;
vi.mock('@core/template-engine/ResponsiveContext', () => ({
ResponsiveContext: React.createContext(null),
ResponsiveProvider: ({ children }: { children?: React.ReactNode }) => children,
useResponsive: () => ({
width: mockWidth,
isMobile: mockWidth < 768,
isTablet: mockWidth >= 768 && mockWidth < 1024,
isDesktop: mockWidth >= 1024,
matchedPreset: (mockWidth < 768 ? 'mobile' : mockWidth < 1024 ? 'tablet' : 'desktop') as
| 'mobile'
| 'tablet'
| 'desktop',
}),
}));
/** id·className 을 그대로 DOM 에 흘리는 스텁 (검사 대상이 그 두 속성이다) */
const Stub: React.FC<Record<string, any>> = ({ children, className, id, text }) => (
<div id={id} className={className}>
{children ?? text}
</div>
);
/**
* ComponentRegistry 싱글톤을 스텁으로 채운다.
*
* Fragment 는 createLayoutTest 가 루트 컨테이너로 쓴다 — 빠뜨리면 자식 트리가
* 통째로 렌더되지 않고 빈 컨테이너만 남는다.
*/
function setupTestRegistry(): void {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Div: { component: Stub, metadata: { name: 'Div', type: 'basic' } },
Span: { component: Stub, metadata: { name: 'Span', type: 'basic' } },
P: { component: Stub, metadata: { name: 'P', type: 'basic' } },
Label: { component: Stub, metadata: { name: 'Label', type: 'basic' } },
Input: { component: Stub, metadata: { name: 'Input', type: 'basic' } },
Select: { component: Stub, metadata: { name: 'Select', type: 'basic' } },
Fragment: {
component: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
metadata: { name: 'Fragment', type: 'layout' },
},
};
}
/** 슬롯 컴포넌트 트리를 독립 레이아웃으로 감싼다 (확장 JSON 은 extension_point 채움용이다) */
function layoutWithSlotComponents() {
return {
version: '1.0',
layout_name: 'test_checkout_cash_receipt',
data_sources: [],
components: JSON.parse(JSON.stringify((extensionJson as any).components)),
};
}
/**
* 루트 gate 가 읽는 paymentSettings 응답.
*
* 호스트 레이아웃(shop/checkout.json)이 선언하는 데이터소스라 슬롯 JSON 에는 없다.
* 반응형 검증이 목적이므로 fetch 를 태우지 않고 initialData 로 직접 주입한다.
*/
const PAYMENT_SETTINGS = {
data: { order_settings: { cash_receipt_provider: 'tosspayments' } },
};
/**
* 신청 폼이 펼쳐진 상태로 렌더한다.
*
* 두 개의 gate 를 모두 열어야 검사 대상 노드가 마운트된다:
* - 루트: paymentSettings.data.order_settings.cash_receipt_provider 가 truthy
* - 입력 영역: _local.cashReceiptRequested 가 truthy
*
* @param width 주입할 화면 너비 (px)
*/
async function renderAtWidth(width: number) {
mockWidth = width;
setupTestRegistry();
const testUtils = createLayoutTest(layoutWithSlotComponents() as any, {
initialData: { paymentSettings: PAYMENT_SETTINGS },
initialState: {
_local: {
paymentMethod: 'dbank',
cashReceiptRequested: true,
cashReceiptType: 'income',
cashReceiptIdentifierType: 'phone',
},
},
});
await testUtils.render();
// 두 gate 가 모두 열렸는지 확인한다. 트리가 비면 아래 검사들이 조용히 통과해 버린다.
expect(
document.getElementById('ext_checkout_cash_receipt_fields'),
'신청 폼이 펼쳐지지 않았다 — gate 조건이 바뀌었는지 확인할 것'
).not.toBeNull();
return testUtils;
}
/**
* 렌더된 노드의 클래스 목록을 반환한다.
*
* @param id 레이아웃 JSON 에 선언된 노드 id
* @return 클래스 토큰 배열
*/
const classesOf = (id: string): string[] => {
const el = document.getElementById(id);
expect(el, `#${id} 가 렌더되지 않았다`).not.toBeNull();
return (el!.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
};
describe('현금영수증 신청 폼 — 반응형 실렌더 (F5)', () => {
beforeEach(() => {
mockWidth = 1280;
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('데스크톱(1280px)에서는 용도 라디오가 가로 배치된다', async () => {
const t = await renderAtWidth(1280);
const cls = classesOf('ext_checkout_cash_receipt_purpose');
expect(cls).toContain('flex-row');
expect(cls).not.toContain('flex-col');
t.cleanup();
});
it('모바일(375px)에서는 용도 라디오가 세로 스택으로 바뀐다', async () => {
const t = await renderAtWidth(375);
const cls = classesOf('ext_checkout_cash_receipt_purpose');
expect(cls).toContain('flex-col');
expect(cls).not.toContain('flex-row');
t.cleanup();
});
it('모바일에서 발급수단·번호 입력행이 1열로 접힌다', async () => {
const t = await renderAtWidth(375);
const cls = classesOf('ext_checkout_cash_receipt_identifier_row');
expect(cls).toContain('grid-cols-1');
expect(cls).not.toContain('grid-cols-2');
t.cleanup();
});
it('태블릿(1023px)까지 portable 이 적용되고 1024px 부터 base 로 돌아온다', async () => {
// portable 은 0~1023px 단일 오버라이드 — 경계값을 양쪽에서 고정한다.
const tablet = await renderAtWidth(1023);
expect(classesOf('ext_checkout_cash_receipt_purpose')).toContain('flex-col');
tablet.cleanup();
cleanup();
const desktop = await renderAtWidth(1024);
expect(classesOf('ext_checkout_cash_receipt_purpose')).toContain('flex-row');
desktop.cleanup();
});
it('어떤 너비에서도 Tailwind 브레이크포인트 접두사를 쓰지 않는다 (§6-0)', async () => {
const t = await renderAtWidth(375);
const rendered = document.body.innerHTML;
expect(rendered).not.toMatch(/\bmd:/);
expect(rendered).not.toMatch(/\blg:/);
expect(rendered).not.toMatch(/\bsm:/);
t.cleanup();
});
});
@@ -0,0 +1,588 @@
/**
* 현금영수증 UI 구조·조건 검증 (#454 S3)
*
* @description
* 다섯 개 표면을 한 곳에서 고정한다:
* - W-1 체크아웃 신청 폼 (extension_point 주입) — 확장 병합 칸(_local.checkoutExtraPayload) 기입
* - W-2 유저 주문상세 카드 (extension_point 주입) — 상태머신 5종
* - W-3 관리자 주문상세 카드 + 발급 모달
* - W-4 취소 모달 환불계좌
* - W-5 환경설정 (프로바이더 / 배송비 과세 / 자진발급)
*
* 조건식은 문자열 매칭이 아니라 실제 엔진(evaluateStringCondition)으로 평가한다.
*
* @vitest-environment node
*/
import { describe, it, expect } from 'vitest';
import { DataBindingEngine } from '@core/template-engine/DataBindingEngine';
import { evaluateStringCondition } from '@core/template-engine/helpers/ConditionEvaluator';
import checkoutExt from '../../../extensions/checkout_cash_receipt.json';
import mypageExt from '../../../extensions/mypage_order_cash_receipt.json';
import paymentInfo from '../../../layouts/admin/partials/admin_ecommerce_order_detail/_partial_payment_info.json';
import issueModal from '../../../layouts/admin/partials/admin_ecommerce_order_detail/_modal_issue_cash_receipt.json';
import cancelModal from '../../../layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json';
import orderSettingsTab from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json';
const engine = new DataBindingEngine();
const evalIf = (expr: string, ctx: Record<string, any>) => evaluateStringCondition(expr, ctx, engine);
/** 트리 전체를 평탄화 (children 뿐 아니라 모든 객체 값을 순회) */
function flatten(node: any, acc: any[] = []): any[] {
if (!node || typeof node !== 'object') return acc;
acc.push(node);
for (const v of Object.values(node)) {
if (Array.isArray(v)) v.forEach((x) => flatten(x, acc));
else if (v && typeof v === 'object') flatten(v, acc);
}
return acc;
}
const byId = (root: any, id: string) => flatten(root).find((n) => n.id === id);
/**
* 결제행 iteration 안의 노드를 id 로 찾는다.
*
* iteration 안에서는 같은 id 가 행마다 중복되므로 index_var(payIdx)로 행마다 고유해야 한다
* (audit `layout-iteration-static-id`). 조회 키에도 접미사를 붙여 그 규약을 함께 고정한다 —
* 누군가 id 를 정적으로 되돌리면 이 헬퍼가 찾지 못해 테스트가 깨진다.
*/
const byRowId = (root: any, id: string) => byId(root, `${id}_{{payIdx}}`);
// ------------------------------------------------------------------ W-1 체크아웃
describe('W-1 체크아웃 현금영수증 신청 폼', () => {
const root = byId(checkoutExt, 'ext_checkout_cash_receipt');
it('올바른 확장점에 append 로 주입한다', () => {
expect(checkoutExt.extension_point).toBe('shop_checkout_cash_receipt_slot');
expect(checkoutExt.mode).toBe('append');
});
it('프로바이더 미설정이면 슬롯 전체가 렌더되지 않는다 (M2)', () => {
const ctx = (provider: string) => ({ paymentSettings: { data: { order_settings: { cash_receipt_provider: provider } } } });
expect(evalIf(root.if, ctx('tosspayments'))).toBe(true);
expect(evalIf(root.if, ctx(''))).toBe(false);
expect(evalIf(root.if, { paymentSettings: { data: { order_settings: {} } } })).toBe(false);
});
it('신청 전에는 하위 필드가 렌더되지 않는다 (M4: 0 → 3)', () => {
const fields = byId(checkoutExt, 'ext_checkout_cash_receipt_fields');
expect(evalIf(fields.if, { _local: {} })).toBe(false);
expect(evalIf(fields.if, { _local: { cashReceiptRequested: true } })).toBe(true);
});
it('신청 체크박스는 확장 병합 칸에 4키를 기입한다 (직접 _local 기입은 서버 미도달)', () => {
const toggle = byId(checkoutExt, 'ext_checkout_cash_receipt_toggle');
const params = toggle.actions[0].params;
expect(params.checkoutExtraPayload).toBeDefined();
// 체크 시: 4키 편입
const on = engine.evaluateExpression(
String(params.checkoutExtraPayload).slice(2, -2),
{ $event: { target: { checked: true } }, _local: {} }
);
expect(on).toEqual({
cash_receipt_requested: true,
cash_receipt_type: 'income',
cash_receipt_identifier_type: 'phone',
cash_receipt_identifier: '',
});
// 해제 시: 빈 객체 → 주문 payload 에서 사라진다
const off = engine.evaluateExpression(
String(params.checkoutExtraPayload).slice(2, -2),
{ $event: { target: { checked: false } }, _local: {} }
);
expect(off).toEqual({});
});
it('소득공제로 전환하면 사업자등록번호 선택이 휴대폰번호로 리셋된다 (M5)', () => {
const purpose = byId(checkoutExt, 'ext_checkout_cash_receipt_purpose');
const incomeRadio = flatten(purpose).find((n) => n.props?.value === 'income');
const params = incomeRadio.actions[0].params;
const ctx = { _local: { cashReceiptIdentifierType: 'business', cashReceiptIdentifier: '1234567890' } };
const nextType = engine.evaluateExpression(String(params.cashReceiptIdentifierType).slice(2, -2), ctx);
const nextId = engine.evaluateExpression(String(params.cashReceiptIdentifier).slice(2, -2), ctx);
expect(nextType).toBe('phone');
expect(nextId).toBe('');
});
it('지출증빙 전환 시에는 휴대폰번호 선택을 유지한다 (D10 — 휴대폰은 양쪽 유효)', () => {
const purpose = byId(checkoutExt, 'ext_checkout_cash_receipt_purpose');
const expenseRadio = flatten(purpose).find((n) => n.props?.value === 'expense');
const payload = engine.evaluateExpression(
String(expenseRadio.actions[0].params.checkoutExtraPayload).slice(2, -2),
{ _local: { cashReceiptIdentifierType: 'phone', cashReceiptIdentifier: '01012345678' } }
);
expect(payload.cash_receipt_type).toBe('expense');
expect(payload.cash_receipt_identifier_type).toBe('phone');
expect(payload.cash_receipt_identifier).toBe('01012345678');
});
it('발급수단 options 는 용도에 따라 2종 / 3종이다', () => {
const select = byId(checkoutExt, 'ext_checkout_cash_receipt_identifier_type');
const expr = String(select.props.options).slice(2, -2);
const t = (k: string) => k;
const income = engine.evaluateExpression(expr, { _local: { cashReceiptType: 'income' }, $t: t });
const expense = engine.evaluateExpression(expr, { _local: { cashReceiptType: 'expense' }, $t: t });
expect(income.map((o: any) => o.value)).toEqual(['phone', 'card']);
expect(expense.map((o: any) => o.value)).toEqual(['business', 'phone', 'card']);
});
it('발급수단을 바꾸면 번호를 비운다 (형식 불일치로 인한 422 방지)', () => {
const select = byId(checkoutExt, 'ext_checkout_cash_receipt_identifier_type');
expect(select.actions[0].params.cashReceiptIdentifier).toBe('');
});
it('Select 는 valueKey/labelKey prop 을 쓰지 않는다', () => {
const select = byId(checkoutExt, 'ext_checkout_cash_receipt_identifier_type');
expect(select.props.valueKey).toBeUndefined();
expect(select.props.labelKey).toBeUndefined();
});
});
// ------------------------------------------------------------------ W-2 유저 주문상세
describe('W-2 유저 주문상세 현금영수증 카드 — 상태머신 5종', () => {
const card = byId(mypageExt, 'ext_mypage_cash_receipt_card');
const payment = (over: Record<string, any> = {}) => ({
payment_method: 'dbank',
cash_receipt_provider: 'tosspayments',
payment_status: 'paid',
...over,
});
it('① 무통장이 아니거나 프로바이더 미설정이면 카드 자체를 렌더하지 않는다', () => {
expect(evalIf(card.if, { order: { data: { payment: payment() } } })).toBe(true);
expect(evalIf(card.if, { order: { data: { payment: payment({ payment_method: 'card' }) } } })).toBe(false);
expect(evalIf(card.if, { order: { data: { payment: payment({ cash_receipt_provider: null }) } } })).toBe(false);
});
it('② 입금 전에는 안내만 노출한다 — 무통장은 ready, 가상계좌는 waiting_deposit', () => {
const node = byId(mypageExt, 'ext_mypage_cash_receipt_awaiting');
// PaymentStatusEnum::isAwaitingDeposit() 이 true 인 두 상태 모두 "입금 전" 이다.
// 무통장 주문은 ready 로 생성된다 — waiting_deposit 만 검사하면 무통장 전부에서 안내가 사라진다.
expect(evalIf(node.if, { order: { data: { payment: payment({ payment_status: 'ready' }) } } })).toBe(true);
expect(evalIf(node.if, { order: { data: { payment: payment({ payment_status: 'waiting_deposit' }) } } })).toBe(true);
expect(evalIf(node.if, { order: { data: { payment: payment({ payment_status: 'paid' }) } } })).toBe(false);
expect(evalIf(node.if, { order: { data: { payment: payment({ payment_status: 'cancelled' }) } } })).toBe(false);
});
it('입금 전(ready) 주문은 5개 상태 중 정확히 하나에 매칭된다 (빈 카드 방지)', () => {
const ctx = { order: { data: { payment: payment({ payment_status: 'ready' }), cash_receipt: null, cash_receipts: [] } } };
const states = [
'ext_mypage_cash_receipt_awaiting',
'ext_mypage_cash_receipt_issuable',
'ext_mypage_cash_receipt_issued',
'ext_mypage_cash_receipt_failed',
];
const matched = states.filter((id) => evalIf(byId(mypageExt, id).if, ctx));
expect(matched).toEqual(['ext_mypage_cash_receipt_awaiting']);
});
it('③ 입금완료 + 미발급 → 발급 버튼', () => {
const node = byId(mypageExt, 'ext_mypage_cash_receipt_issuable');
expect(evalIf(node.if, { order: { data: { payment: payment(), cash_receipt: null, cash_receipts: [] } } })).toBe(true);
expect(evalIf(node.if, { order: { data: { payment: payment(), cash_receipt: { id: 1 }, cash_receipts: [] } } })).toBe(false);
});
it('입금완료 주문의 상태 ③ 과 ⑤ 는 동시에 매칭되지 않는다 (배타)', () => {
const failedCtx = { order: { data: { payment: payment(), cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } };
const issuable = byId(mypageExt, 'ext_mypage_cash_receipt_issuable');
const failed = byId(mypageExt, 'ext_mypage_cash_receipt_failed');
expect(evalIf(failed.if, failedCtx)).toBe(true);
expect(evalIf(issuable.if, failedCtx)).toBe(false);
});
it('④ 발급완료 → 영수증 링크', () => {
const node = byId(mypageExt, 'ext_mypage_cash_receipt_issued');
expect(evalIf(node.if, { order: { data: { cash_receipt: { id: 1 } } } })).toBe(true);
expect(evalIf(node.if, { order: { data: { cash_receipt: null } } })).toBe(false);
});
it('⑤ 취소 성공 + 재발급 실패 → 경고 (issue_status 는 대문자 FAILED)', () => {
const node = byId(mypageExt, 'ext_mypage_cash_receipt_failed');
const failed = { order: { data: { cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } };
const ok = { order: { data: { cash_receipt: null, cash_receipts: [{ issue_status: 'COMPLETED' }] } } };
const none = { order: { data: { cash_receipt: null, cash_receipts: [] } } };
expect(evalIf(node.if, failed)).toBe(true);
expect(evalIf(node.if, ok)).toBe(false);
expect(evalIf(node.if, none)).toBe(false);
});
it('유저에게는 발급취소·수동 재발급 버튼을 주지 않는다 (관리자 전용)', () => {
// 'reissue_failed' 안내 문구는 정상 — 금지 대상은 재발급/발급취소를 실행하는 액션이다.
const actions = flatten(mypageExt).flatMap((n: any) => n.actions ?? []);
const apiCalls = actions.filter((a: any) => a.handler === 'apiCall');
expect(apiCalls.some((a: any) => String(a.target).includes('/reissue'))).toBe(false);
expect(apiCalls.some((a: any) => a.params?.method === 'DELETE')).toBe(false);
// 남은 apiCall 은 발급(POST) 하나뿐이어야 한다
expect(apiCalls.map((a: any) => a.params?.method)).toEqual(['POST']);
});
it('발급 모달은 modals 섹션에 등록한다 (인라인 Modal 금지)', () => {
expect(Array.isArray(mypageExt.modals)).toBe(true);
expect(mypageExt.modals[0].id).toBe('ext_cash_receipt_issue_modal');
});
it('회원은 user/orders/{id}, 비회원은 guest/orders/{orderNumber} 로 발급한다', () => {
const submit = byId(mypageExt, 'ext_cash_receipt_issue_submit');
const expr = String(submit.actions[0].target).slice(2, -2);
const member = engine.evaluateExpression(expr, {
_global: { currentUser: { uuid: 'u1' } },
order: { data: { id: 42, order_number: 'ORD-1' } },
});
const guest = engine.evaluateExpression(expr, {
_global: { currentUser: null },
order: { data: { id: 42, order_number: 'ORD-1' } },
});
expect(member).toBe('/api/modules/sirsoft-ecommerce/user/orders/42/cash-receipt');
expect(guest).toBe('/api/modules/sirsoft-ecommerce/guest/orders/ORD-1/cash-receipt');
});
});
// ------------------------------------------------------------------ W-3 관리자 주문상세
describe('W-3 관리자 주문상세 현금영수증 카드', () => {
const card = byRowId(paymentInfo, 'payment_cash_receipt_card');
it('결제카드 반복(payment) 컨텍스트에서 무통장 + 프로바이더 설정 시에만 렌더된다', () => {
expect(evalIf(card.if, { payment: { payment_method: 'dbank', cash_receipt_provider: 'toss' } })).toBe(true);
expect(evalIf(card.if, { payment: { payment_method: 'vbank', cash_receipt_provider: 'toss' } })).toBe(false);
expect(evalIf(card.if, { payment: { payment_method: 'dbank', cash_receipt_provider: '' } })).toBe(false);
});
it('입금 전 안내는 무통장(ready)과 가상계좌(waiting_deposit) 모두에서 노출된다', () => {
const node = byRowId(paymentInfo, 'payment_cash_receipt_awaiting');
expect(evalIf(node.if, { payment: { payment_status: 'ready' } })).toBe(true);
expect(evalIf(node.if, { payment: { payment_status: 'waiting_deposit' } })).toBe(true);
expect(evalIf(node.if, { payment: { payment_status: 'paid' } })).toBe(false);
});
it('관리자 전용 버튼 3종(발급/발급취소/수동 재발급)이 존재한다', () => {
expect(byRowId(paymentInfo, 'payment_cash_receipt_issue_button')).toBeDefined();
expect(byRowId(paymentInfo, 'payment_cash_receipt_cancel_button')).toBeDefined();
expect(byRowId(paymentInfo, 'payment_cash_receipt_reissue_button')).toBeDefined();
});
it('재발급 실패 상태에서 발급 버튼과 재발급 버튼이 동시에 노출되지 않는다', () => {
// 실패 이력이 있으면 복구는 "수동 재발급" 경로다. 신규 발급 버튼을 함께 띄우면
// 관리자가 어느 쪽을 눌러야 하는지 알 수 없다.
const failed = { payment: { payment_status: 'paid' }, order: { data: { cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } };
const issue = byRowId(paymentInfo, 'payment_cash_receipt_issue_button');
const reissue = byRowId(paymentInfo, 'payment_cash_receipt_reissue_button');
expect(evalIf(reissue.if, failed)).toBe(true);
expect(evalIf(issue.if, failed)).toBe(false);
});
it('이력이 전혀 없는 입금완료 주문에서는 발급 버튼만 노출된다', () => {
const fresh = { payment: { payment_status: 'paid' }, order: { data: { cash_receipt: null, cash_receipts: [] } } };
expect(evalIf(byRowId(paymentInfo, 'payment_cash_receipt_issue_button').if, fresh)).toBe(true);
expect(evalIf(byRowId(paymentInfo, 'payment_cash_receipt_reissue_button').if, fresh)).toBe(false);
});
it('수동 재발급 버튼은 재발급 실패 상태에서만 노출된다', () => {
const btn = byRowId(paymentInfo, 'payment_cash_receipt_reissue_button');
const failed = { order: { data: { cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } };
const issued = { order: { data: { cash_receipt: { id: 1 }, cash_receipts: [{ issue_status: 'COMPLETED' }] } } };
expect(evalIf(btn.if, failed)).toBe(true);
expect(evalIf(btn.if, issued)).toBe(false);
});
it('관리자 apiCall 은 모두 auth_required 를 선언한다 (미선언 시 Bearer 토큰 미부착 → 401)', () => {
const adminApiCalls = [
...flatten(paymentInfo).flatMap((n: any) => n.actions ?? []),
...flatten(issueModal).flatMap((n: any) => n.actions ?? []),
].filter((a: any) => a.handler === 'apiCall' && String(a.target).includes('/admin/'));
expect(adminApiCalls.length).toBeGreaterThan(0);
for (const a of adminApiCalls) {
expect(a.auth_required, `auth_required 누락: ${a.target}`).toBe(true);
}
});
it('발급취소는 DELETE, 재발급은 reissue 엔드포인트를 호출한다', () => {
const cancel = byRowId(paymentInfo, 'payment_cash_receipt_cancel_button');
const reissue = byRowId(paymentInfo, 'payment_cash_receipt_reissue_button');
expect(cancel.actions[0].params.method).toBe('DELETE');
expect(reissue.actions[0].target).toContain('/cash-receipt/reissue');
});
it('발급 이력표는 이력이 있을 때만, 그리고 기본 접힌 상태로 존재한다', () => {
const wrap = byRowId(paymentInfo, 'payment_cash_receipt_history');
const body = byRowId(paymentInfo, 'payment_cash_receipt_history_body');
// 껍데기: 이력 0건이면 토글 헤더조차 띄우지 않는다
expect(evalIf(wrap.if, { order: { data: { cash_receipts: [] } } })).toBe(false);
expect(evalIf(wrap.if, { order: { data: { cash_receipts: [{ id: 1 }] } } })).toBe(true);
// 본문: 초기 상태(_local.expandedCashReceiptHistory = [])에서 접혀 있어야 한다
expect(evalIf(body.if, { payIdx: 0, _local: { expandedCashReceiptHistory: [] } })).toBe(false);
expect(evalIf(body.if, { payIdx: 0, _local: { expandedCashReceiptHistory: [0] } })).toBe(true);
// 상태 키가 아직 초기화되지 않은 순간에도 터지지 않는다 (?? [] fallback)
expect(evalIf(body.if, { payIdx: 0, _local: {} })).toBe(false);
});
it('이력 토글은 결제 건마다 독립적으로 접히고 펼쳐진다', () => {
// 한 주문에 결제행이 여러 개일 수 있다. payIdx 를 배열에 넣고 빼는 방식이라야
// 0번 행을 펼쳐도 1번 행이 함께 펼쳐지지 않는다.
const toggle = byRowId(paymentInfo, 'payment_cash_receipt_history_toggle');
const body = byRowId(paymentInfo, 'payment_cash_receipt_history_body');
const expr = String(toggle.actions[0].params.expandedCashReceiptHistory).slice(2, -2);
// 0번 행 펼치기
const afterOpen0 = engine.evaluateExpression(expr, { payIdx: 0, _local: { expandedCashReceiptHistory: [] } });
expect(afterOpen0).toEqual([0]);
// 그 상태에서 1번 행 펼치기 → 0번은 그대로 열려 있다
const afterOpen1 = engine.evaluateExpression(expr, { payIdx: 1, _local: { expandedCashReceiptHistory: afterOpen0 } });
expect(afterOpen1).toEqual([0, 1]);
expect(evalIf(body.if, { payIdx: 0, _local: { expandedCashReceiptHistory: afterOpen1 } })).toBe(true);
// 0번 행 다시 접기 → 1번은 열린 채 남는다
const afterClose0 = engine.evaluateExpression(expr, { payIdx: 0, _local: { expandedCashReceiptHistory: afterOpen1 } });
expect(afterClose0).toEqual([1]);
expect(evalIf(body.if, { payIdx: 0, _local: { expandedCashReceiptHistory: afterClose0 } })).toBe(false);
expect(evalIf(body.if, { payIdx: 1, _local: { expandedCashReceiptHistory: afterClose0 } })).toBe(true);
});
it('토글 버튼은 접힘 상태를 아이콘과 aria-expanded 로 함께 알린다', () => {
const toggle = byRowId(paymentInfo, 'payment_cash_receipt_history_toggle');
const body = byRowId(paymentInfo, 'payment_cash_receipt_history_body');
// Form 밖이지만 submit 오작동 방지 규약을 따른다
expect(toggle.props.type).toBe('button');
// 스크린리더가 본문 존재를 알 수 있어야 한다
expect(toggle.props['aria-controls']).toBe(body.id);
const icon = flatten(toggle).find((n: any) => n.name === 'Icon');
const iconExpr = String(icon.props.name).slice(2, -2);
const ariaExpr = String(toggle.props['aria-expanded']).slice(2, -2);
const closed = { payIdx: 0, _local: { expandedCashReceiptHistory: [] } };
const opened = { payIdx: 0, _local: { expandedCashReceiptHistory: [0] } };
expect(engine.evaluateExpression(iconExpr, closed)).toBe('chevron-right');
expect(engine.evaluateExpression(iconExpr, opened)).toBe('chevron-down');
expect(engine.evaluateExpression(ariaExpr, closed)).toBe(false);
expect(engine.evaluateExpression(ariaExpr, opened)).toBe(true);
// Icon 은 w-N/h-N 클래스가 아니라 size prop 으로 크기를 지정한다
expect(icon.props.size).toBeDefined();
expect(icon.props.className ?? '').not.toMatch(/\b[wh]-\d/);
});
it('액션 영역은 데스크톱 가로 → portable 세로 스택 (§6-0)', () => {
const actions = byRowId(paymentInfo, 'payment_cash_receipt_actions');
expect(actions.props.className).toContain('flex-row');
expect(actions.responsive.portable.props.className).toContain('flex-col');
expect(Object.keys(actions.responsive)).toEqual(['portable']);
});
it('신설 카드에 Tailwind breakpoint(md:/lg:) 를 쓰지 않는다', () => {
for (const n of flatten(card)) {
expect(n.props?.className ?? '').not.toMatch(/(^|\s)(md|lg|xl|2xl):/);
}
});
});
describe('W-3 관리자 발급 모달', () => {
it('modals 파트너 id 로 등록되고 발급 버튼이 이를 연다', () => {
expect(issueModal.id).toBe('modal_issue_cash_receipt');
const btn = byRowId(paymentInfo, 'payment_cash_receipt_issue_button');
const open = flatten(btn.actions).find((a: any) => a.handler === 'openModal');
expect(open.target).toBe('modal_issue_cash_receipt');
});
it('발급수단 options 는 용도에 따라 2종 / 3종이다', () => {
const select = byId(issueModal, 'modal_cash_receipt_identifier_type');
const expr = String(select.props.options).slice(2, -2);
const t = (k: string) => k;
const income = engine.evaluateExpression(expr, { _global: { adminCashReceiptForm: { receipt_type: 'income' } }, $t: t });
const expense = engine.evaluateExpression(expr, { _global: { adminCashReceiptForm: { receipt_type: 'expense' } }, $t: t });
expect(income.map((o: any) => o.value)).toEqual(['phone', 'card']);
expect(expense.map((o: any) => o.value)).toEqual(['business', 'phone', 'card']);
});
it('번호가 비어 있으면 발급 버튼이 비활성화된다', () => {
const submit = byId(issueModal, 'modal_cash_receipt_submit');
const expr = String(submit.props.disabled).slice(2, -2);
expect(engine.evaluateExpression(expr, { _global: { adminCashReceiptForm: { identifier: '' } } })).toBe(true);
expect(engine.evaluateExpression(expr, { _global: { adminCashReceiptForm: { identifier: '01012345678' } } })).toBe(false);
});
it('성공 시 setState 를 closeModal 보다 먼저 실행한다 (순서 역전 시 상태 잔존)', () => {
const submit = byId(issueModal, 'modal_cash_receipt_submit');
const onSuccess = submit.actions[0].onSuccess.map((a: any) => a.handler);
expect(onSuccess.indexOf('setState')).toBeLessThan(onSuccess.indexOf('closeModal'));
expect(onSuccess).toContain('refetchDataSource');
});
});
// ------------------------------------------------------------------ W-4 취소 모달
describe('W-4 취소 모달 환불계좌', () => {
const section = byId(cancelModal, 'cancel_refund_bank_section');
it('가상계좌·무통장에서만 노출한다 (카드·간편결제는 계좌 불필요)', () => {
const ctx = (m: string) => ({ order: { data: { payment: { payment_method: m } } } });
expect(evalIf(section.if, ctx('vbank'))).toBe(true);
expect(evalIf(section.if, ctx('dbank'))).toBe(true);
expect(evalIf(section.if, ctx('card'))).toBe(false);
expect(evalIf(section.if, ctx('point'))).toBe(false);
});
it('3필드가 _local.refundBank* 에 저장된다 (cancelOrderHandlers 가 읽는 키)', () => {
const keys = flatten(section)
.flatMap((n: any) => n.actions ?? [])
.filter((a: any) => a.handler === 'setState')
.flatMap((a: any) => Object.keys(a.params).filter((k) => k !== 'target'));
expect(keys).toEqual(expect.arrayContaining(['refundBankCode', 'refundBankAccount', 'refundBankHolder']));
});
it('가상계좌 입금완료 건에만 필수 안내를 띄운다', () => {
const hint = flatten(section).find((n: any) => String(n.text ?? '').includes('refund_bank_required_hint'));
const ctx = (m: string, s: string) => ({ order: { data: { payment: { payment_method: m, payment_status: s } } } });
expect(evalIf(hint.if, ctx('vbank', 'paid'))).toBe(true);
expect(evalIf(hint.if, ctx('vbank', 'waiting_deposit'))).toBe(false);
expect(evalIf(hint.if, ctx('dbank', 'paid'))).toBe(false);
});
it('3열 → portable 1열로 접힌다', () => {
const grid = flatten(section).find((n: any) => (n.props?.className ?? '').includes('grid-cols-3'));
expect(grid.responsive.portable.props.className).toContain('grid-cols-1');
});
});
// ------------------------------------------------------------------ W-5 환경설정
describe('W-5 환경설정 — 현금영수증', () => {
const card = byId(orderSettingsTab, 'cash_receipt_card');
it('카드가 주문설정 탭에 존재한다', () => {
expect(card).toBeDefined();
});
it('등록된 프로바이더가 없으면 Select 대신 안내를 노출한다', () => {
const select = byId(orderSettingsTab, 'cash_receipt_provider_select');
expect(evalIf(select.if, { _local: { form: { available_cash_receipt_providers: [{ id: 'toss' }] } } })).toBe(true);
expect(evalIf(select.if, { _local: { form: { available_cash_receipt_providers: [] } } })).toBe(false);
});
it('배송비 과세 3정책의 value 가 백엔드 Enum 과 일치한다', () => {
const select = byId(orderSettingsTab, 'shipping_fee_tax_policy_select');
const opts = engine.evaluateExpression(String(select.props.options).slice(2, -2), { $t: (k: string) => k });
expect(opts.map((o: any) => o.value)).toEqual(['proportional', 'taxable', 'follow_main_item']);
});
it('배송비 과세 기본값은 안분(proportional) 이다', () => {
const select = byId(orderSettingsTab, 'shipping_fee_tax_policy_select');
const value = engine.evaluateExpression(String(select.props.value).slice(2, -2), { _local: { form: { order_settings: {} } } });
expect(value).toBe('proportional');
});
it('자진발급 토글의 기본값은 OFF 다 (D14)', () => {
const toggle = byId(orderSettingsTab, 'cash_receipt_self_issue_toggle');
const checked = engine.evaluateExpression(String(toggle.props.checked).slice(2, -2), { _local: { form: { order_settings: {} } } });
expect(checked).toBe(false);
});
it('3개 컨트롤이 저장 대상 키에 setState 하고 hasChanges 를 세운다', () => {
const targets = flatten(card)
.flatMap((n: any) => n.actions ?? [])
.filter((a: any) => a.handler === 'setState')
.map((a: any) => a.params);
const keys = targets.flatMap((p: any) => Object.keys(p));
expect(keys).toEqual(expect.arrayContaining([
'form.order_settings.cash_receipt_provider',
'form.order_settings.shipping_fee_tax_policy',
'form.order_settings.cash_receipt_self_issue',
]));
expect(targets.every((p: any) => p.hasChanges === true)).toBe(true);
});
it('자진발급 안내에 국세청 의무발행 업종 링크가 있다', () => {
// 의무발행 업종인지 판단하라고 안내만 하고 확인 경로를 주지 않으면 설정을 켤 수 없다.
const link = byId(card, 'cash_receipt_mandatory_industry_link');
expect(link.name).toBe('A');
expect(link.props.href).toMatch(/^https:\/\/www\.nts\.go\.kr\//);
expect(link.props.target).toBe('_blank');
// 외부 링크는 opener 유출을 막는다.
expect(link.props.rel).toBe('noopener noreferrer');
const labels = flatten(link).map((n: any) => n.text).filter(Boolean);
expect(labels).toContain(
'$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.self_issue_mandatory_link'
);
});
it('신설 카드에 Tailwind breakpoint(md:/lg:) 를 쓰지 않는다 (§6-0)', () => {
for (const n of flatten(card)) {
expect(n.props?.className ?? '').not.toMatch(/(^|\s)(md|lg|xl|2xl):/);
}
});
});
// ------------------------------------------------------------ 5개 표면 공통 규약
describe('현금영수증 5개 표면 공통 규약', () => {
const SURFACES: Array<[string, any]> = [
['W-1 체크아웃', checkoutExt],
['W-2 유저 주문상세', mypageExt],
['W-3 관리자 주문상세', paymentInfo],
['W-3 발급 모달', issueModal],
['W-5 환경설정', orderSettingsTab],
];
it.each(SURFACES)('%s — Icon 크기는 size prop 으로 지정한다 (w-N/h-N 금지)', (_label, root) => {
// audit `layout-icon-classname-size` 와 같은 규약. 그 룰의 appliesTo 는
// `**/resources/layouts/**` 라서 확장점 파일(resources/extensions/**)을 보지 못한다.
// 그 사각을 여기서 덮는다 — 실제로 mypage 확장점의 Icon 2개가 이 규약을 어기고 있었다.
for (const icon of flatten(root).filter((n: any) => n.name === 'Icon')) {
const cls = String(icon.props?.className ?? '');
expect(cls, `Icon(${icon.props?.name}) 에 w-N/h-N 클래스`).not.toMatch(/(^|\s)[wh]-\d/);
}
});
});
@@ -12,27 +12,32 @@
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
import { DataBindingEngine } from '@core/template-engine/DataBindingEngine';
const templatesRoot = path.resolve(__dirname, '../../../../../../../templates/_bundled/sirsoft-basic');
const engine = new DataBindingEngine();
/**
* 재귀적으로 JSON 노드에서 apiCall 핸들러의 body를 찾는다
* 재귀적으로 JSON 노드에서 주문 생성 apiCall 액션을 찾는다.
*
* body 는 통짜 표현식 문자열이다(#454 S3 — 확장 병합 칸). 예전처럼 객체가 아니므로
* `params.body.payment_method` 로는 찾을 수 없고, endpoint 로 식별한다.
*/
function findApiCallBody(node: any): any {
if (node.handler === 'apiCall' && node.params?.body?.payment_method) {
return node.params.body;
function findOrderApiCall(node: any): any {
if (node.handler === 'apiCall' && typeof node.target === 'string' && node.target.endsWith('/user/orders')) {
return node;
}
for (const key of ['actions', 'children']) {
if (Array.isArray(node[key])) {
for (const child of node[key]) {
const found = findApiCallBody(child);
const found = findOrderApiCall(child);
if (found) return found;
}
}
}
if (Array.isArray(node.params?.actions)) {
for (const action of node.params.actions) {
const found = findApiCallBody(action);
const found = findOrderApiCall(action);
if (found) return found;
}
}
@@ -40,7 +45,7 @@ function findApiCallBody(node: any): any {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren as any[]) {
const found = findApiCallBody(child);
const found = findOrderApiCall(child);
if (found) return found;
}
}
@@ -58,28 +63,54 @@ describe('체크아웃 결제수단 회귀 테스트', () => {
);
describe('_checkout_summary.json apiCall body', () => {
const apiCallBody = findApiCallBody(summaryJson);
const orderCall = findOrderApiCall(summaryJson);
it('apiCall body가 존재한다', () => {
expect(apiCallBody).not.toBeNull();
/**
* body 표현식을 실제로 평가해 전송 payload 를 얻는다.
*
* 문자열 매칭 대신 평가하는 이유: 원래 이 테스트가 고정하려던 것은 "무통장을 고르면
* dbank 가 실려 나가는가" 이지 body 가 객체 리터럴인가가 아니다. 표현식으로 바뀐 뒤에도
* 그 행위는 그대로 검증되어야 한다.
*/
const evalBody = (ctx: Record<string, any>) =>
engine.evaluateExpression(String(orderCall.params.body).slice(2, -2), ctx);
const context = (paymentMethod: string, localPaymentMethod?: string) => ({
checkoutData: { data: { temp_order_id: 'T-1', calculation: { summary: { final_amount: 1000 } } } },
_computed: { selectedPaymentMethod: paymentMethod, ordererDefaults: { name: '홍길동' } },
_local: {
paymentMethod: localPaymentMethod,
shipping: {},
selectedDbank: { bank_code: '088', account_number: '110-1', account_holder: '홍길동' },
},
_global: { currentUser: { uuid: 'u-1' } },
});
it('payment_method가 _computed.selectedPaymentMethod를 사용한다', () => {
expect(apiCallBody.payment_method).toBe('{{_computed.selectedPaymentMethod}}');
it('주문 생성 apiCall 이 존재한다', () => {
expect(orderCall).not.toBeNull();
expect(typeof orderCall.params.body).toBe('string');
});
it('payment_method가 _local.paymentMethod를 직접 사용하지 않는다', () => {
// _local.paymentMethod는 사용자가 결제수단 버튼을 클릭해야만 설정됨
// 클릭 전에는 undefined이므로 직접 참조하면 안 됨
expect(apiCallBody.payment_method).not.toContain('_local.paymentMethod');
it('payment_method 는 _computed.selectedPaymentMethod 를 그대로 싣는다', () => {
expect(evalBody(context('dbank')).payment_method).toBe('dbank');
expect(evalBody(context('card')).payment_method).toBe('card');
});
it('dbank 조건이 _computed.selectedPaymentMethod를 사용한다', () => {
expect(apiCallBody.dbank).toContain('_computed.selectedPaymentMethod');
it('_local.paymentMethod 가 비어 있어도 computed 값이 실린다 (기본값 card 로 새지 않는다)', () => {
// 회귀 원인: 사용자가 결제수단 버튼을 누르기 전에는 _local.paymentMethod 가 undefined 다.
// body 가 그것을 직접 참조하면 무통장을 골라도 card 가 전송됐다.
expect(evalBody(context('dbank', undefined)).payment_method).toBe('dbank');
});
it('dbank 조건이 _local.paymentMethod를 직접 사용하지 않는다', () => {
expect(apiCallBody.dbank).not.toContain('_local.paymentMethod');
it('dbank 블록은 무통장일 때만 채워지고 그 외에는 null 이다', () => {
expect(evalBody(context('dbank')).dbank).toMatchObject({ bank_code: '088' });
expect(evalBody(context('card')).dbank).toBeNull();
});
it('_local.paymentMethod 가 computed 와 어긋나도 computed 가 우선한다', () => {
const body = evalBody(context('dbank', 'card'));
expect(body.payment_method).toBe('dbank');
expect(body.dbank).not.toBeNull();
});
});
@@ -208,11 +208,13 @@ describe('주문 목록 partial 구조 검증 (_list.json)', () => {
expect(listJsonStr).not.toContain('order.multi_currency_total');
});
it('단가가 item.unit_price_formatted를 사용해야 함', () => {
// 마이페이지 주문목록은 옵션 소계(mc_subtotal_price) 로 다통화를 표시하고
// 단가는 포맷 문자열(unit_price_formatted) 로만 노출한다. 단가 단위의
it('단가는 결제 통화로 굳은 unit_price_formatted 를 그대로 쓴다', () => {
// 이미 결제가 끝난 주문의 금액은 결제 시점 통화로 고정이다. 사용자가 지금 보고 있는
// 통화(_global.preferredCurrency)로 환산해 보여주면 실제 결제액과 달라진다.
// 서버가 formatOrderCurrency 로 확정한 문자열을 표시만 한다. 단가 단위의
// 다통화 필드(mc_unit_price)는 이 레이아웃에 존재하지 않는다(주문완료 화면 전용).
expect(listJsonStr).toContain('item.unit_price_formatted');
expect(listJsonStr).not.toContain('item.mc_unit_price');
expect(listJsonStr).not.toContain('item.multi_currency_unit_price');
});
@@ -220,12 +222,15 @@ describe('주문 목록 partial 구조 검증 (_list.json)', () => {
expect(listJsonStr).toContain('item.subtotal_price_formatted');
});
it('다통화 소계가 item.mc_subtotal_price를 사용해야 함', () => {
it('다통화 소계 목록은 결제 통화를 제외하고 나열한다', () => {
// 소계만 다통화 병기를 남긴다 — 참고 표시이므로 결제 통화 행은 중복이라 걸러낸다.
expect(listJsonStr).toContain('item.mc_subtotal_price');
expect(listJsonStr).toContain('order.base_currency');
});
it('배송비가 order.total_shipping_amount를 사용해야 함', () => {
it('배송비는 결제 통화로 굳은 total_shipping_amount_formatted 를 그대로 쓴다', () => {
expect(listJsonStr).toContain('order.total_shipping_amount');
expect(listJsonStr).toContain('order.total_shipping_amount_formatted');
});
it('다통화 총액이 order.mc_total_amount를 사용해야 함', () => {
@@ -416,17 +416,24 @@ describe('admin_ecommerce_order_detail.json (메인 레이아웃)', () => {
expect(Object.keys(layout.computed)).toHaveLength(0);
});
it('modals에 6개의 모달 partial이 정의되어 있다 (reset_guest_password + confirm_deposit 추가)', () => {
it('modals에 등록된 모달 partial 집합이 정확히 일치한다', () => {
const layout = mainLayout as any;
expect(Array.isArray(layout.modals)).toBe(true);
expect(layout.modals).toHaveLength(6);
// 개수와 이름을 따로 단언하면 모달 추가 시 개수만 어긋나 무엇이 늘었는지 드러나지 않는다.
// 집합 자체를 고정해 추가·삭제가 진단 가능하도록 한다.
const partialPaths = layout.modals.map((m: any) => m.partial);
expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json');
expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_send_sms.json');
expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_send_email.json');
expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_cancel_order.json');
expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_reset_guest_password.json');
expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_confirm_deposit.json');
expect(new Set(partialPaths)).toEqual(
new Set([
'partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json',
'partials/admin_ecommerce_order_detail/_modal_send_sms.json',
'partials/admin_ecommerce_order_detail/_modal_send_email.json',
'partials/admin_ecommerce_order_detail/_modal_cancel_order.json',
'partials/admin_ecommerce_order_detail/_modal_issue_cash_receipt.json',
'partials/admin_ecommerce_order_detail/_modal_reset_guest_password.json',
'partials/admin_ecommerce_order_detail/_modal_confirm_deposit.json',
])
);
});
});
@@ -277,6 +277,19 @@ export async function executeCancelOrderHandler(
body.reason_detail = cancelReasonDetail;
}
// 환불 계좌 — 관리자가 취소 모달에서 입력/수정한 값.
// 셋 중 하나라도 채워졌으면 채워진 그대로 보낸다. 부분 입력 거부와 "가상계좌 입금완료 건은 필수"
// 판정은 서버(CancelOrderRequest)가 단독으로 수행한다 — 프론트에서 중복 판정하지 않는다.
// 아무것도 입력하지 않았으면 키 자체를 보내지 않아, 주문 시 입력된 계좌가 지워지지 않는다.
const refundBank = {
bank_code: local.refundBankCode || '',
account_number: local.refundBankAccount || '',
holder: local.refundBankHolder || '',
};
if (refundBank.bank_code || refundBank.account_number || refundBank.holder) {
body.refund_bank = refundBank;
}
G7Core.state.setLocal({ isCancelling: true, cancelError: null, cancelValidationErrors: null });
try {
@@ -34,6 +34,7 @@
"order": "Order",
"order_logs": "Order Logs",
"orders": "Orders",
"paymentSettings": "Payment Settings",
"policy": "Shipping Policy",
"presets": "Presets",
"product": "Product",
@@ -192,5 +193,19 @@
},
"shop": {
"$partial": "partial/en/shop.json"
},
"checkout": {
"cash_receipt": {
"request": "Request a cash receipt",
"purpose": "Purpose",
"purpose_income": "Income deduction",
"purpose_expense": "Proof of expenditure",
"identifier_phone": "Mobile number",
"identifier_card": "Cash receipt card number",
"identifier_business": "Business registration number",
"identifier_placeholder": "Digits only, no hyphens",
"purpose_help": "Income deduction is used for year-end tax settlement; proof of expenditure is used for business input VAT deduction.",
"immutable_help": "The purpose cannot be changed after the receipt is issued."
}
}
}
@@ -34,6 +34,7 @@
"order": "주문",
"order_logs": "주문 로그",
"orders": "주문 목록",
"paymentSettings": "결제 환경설정",
"policy": "배송 정책",
"presets": "프리셋 목록",
"product": "상품",
@@ -192,5 +193,19 @@
},
"shop": {
"$partial": "partial/ko/shop.json"
},
"checkout": {
"cash_receipt": {
"request": "현금영수증 신청",
"purpose": "용도",
"purpose_income": "소득공제용",
"purpose_expense": "지출증빙용",
"identifier_phone": "휴대폰번호",
"identifier_card": "현금영수증 카드번호",
"identifier_business": "사업자등록번호",
"identifier_placeholder": "- 없이 숫자만 입력",
"purpose_help": "소득공제용은 연말정산에, 지출증빙용은 사업자 매입세액공제에 사용됩니다.",
"immutable_help": "발급 후에는 용도를 변경할 수 없습니다."
}
}
}
@@ -286,7 +286,13 @@
"close_btn": "Close",
"estimate_failed": "Failed to calculate estimated refund.",
"cancel_success": "Order has been cancelled.",
"cancel_failed": "Failed to cancel order."
"cancel_failed": "Failed to cancel order.",
"refund_bank_label": "Refund account",
"refund_bank_select": "Select bank",
"refund_bank_account_placeholder": "Account number (no hyphens)",
"refund_bank_holder_placeholder": "Account holder",
"refund_bank_required_hint": "A refund account is required for a paid virtual account order.",
"refund_bank_partial_notice": "If you fill in any one of the three fields, the others are required."
}
},
"claim_history": {
@@ -450,6 +456,38 @@
"after": "After",
"bulk_model_id": "ID: {{id}}"
}
},
"cash_receipt": {
"title": "Cash Receipt",
"issue_title": "Issue a cash receipt",
"status_issued": "Issued",
"awaiting_deposit": "The receipt can be issued once the deposit is confirmed.",
"purpose": "Purpose",
"purpose_income": "Income deduction",
"purpose_expense": "Proof of expenditure",
"identifier_type": "Identifier type",
"identifier": "Identifier",
"identifier_phone": "Mobile number",
"identifier_card": "Cash receipt card number",
"identifier_business": "Business registration number",
"identifier_placeholder": "Digits only, no hyphens",
"immutable_help": "The purpose cannot be changed after the receipt is issued.",
"amount": "Amount",
"tax_free_amount": "Tax-free amount",
"issue_number": "Issue number",
"issued_at": "Issued at",
"issue": "Issue cash receipt",
"cancel": "Cancel",
"cancel_issue": "Cancel issuance",
"reissue": "Reissue manually",
"view_receipt": "View receipt",
"history": "Issue history ({{count}})",
"issue_success": "The cash receipt has been issued.",
"issue_failed": "Failed to issue the cash receipt.",
"cancel_success": "The cash receipt issuance has been cancelled.",
"cancel_failed": "Failed to cancel the cash receipt issuance.",
"reissue_failed": "Reissue failed",
"reissue_success": "The cash receipt has been reissued."
}
},
"preset": {
@@ -219,6 +219,23 @@
"restore_on_cancel": "Restore Stock on Cancel",
"restore_on_cancel_description": "Automatically restore deducted stock when an order is cancelled.",
"restore_on_cancel_hint": "Also applies to returns and exchanges."
},
"cash_receipt": {
"title": "Cash Receipt",
"description": "Configure the cash receipt provider and how shipping fees are taxed.",
"provider_label": "Cash receipt provider",
"provider_none": "(Not used)",
"provider_not_installed": "Activate a payment plugin to choose an issuing provider.",
"provider_help": "Can be chosen independently of the PG used for payment.",
"shipping_fee_tax_label": "Shipping fee tax method",
"shipping_fee_tax_proportional": "Proportional (default)",
"shipping_fee_tax_taxable": "Fully taxable",
"shipping_fee_tax_follow_main_item": "Follow the main item",
"shipping_fee_tax_help": "Proportional splits the shipping fee by the taxable and tax-free product amounts.",
"self_issue_label": "Enable self-issuance",
"self_issue_help": "Automatically issues receipts for bank transfer orders even when the buyer did not request one (income deduction only).",
"self_issue_mandatory_help": "Businesses required to issue cash receipts must do so even without a buyer request. Turn this on if that applies to you.",
"self_issue_mandatory_link": "NTS guide to mandatory-issuance industries"
}
},
"claim": {
@@ -12,5 +12,29 @@
"delivered": "Delivered",
"cancelled": "Cancelled",
"refunded": "Refunded"
},
"cash_receipt": {
"title": "Cash Receipt",
"issue_title": "Issue a cash receipt",
"purpose": "Purpose",
"purpose_income": "Income deduction",
"purpose_expense": "Proof of expenditure",
"identifier_type": "Identifier type",
"identifier": "Identifier",
"identifier_phone": "Mobile number",
"identifier_card": "Cash receipt card number",
"identifier_business": "Business registration number",
"identifier_placeholder": "Digits only, no hyphens",
"immutable_help": "The purpose cannot be changed after the receipt is issued.",
"awaiting_deposit": "The receipt will be issued once the deposit is confirmed.",
"none": "No cash receipt has been issued.",
"issue": "Issue cash receipt",
"cancel": "Cancel",
"issued_at": "Issued at",
"view_receipt": "View receipt",
"issue_success": "The cash receipt has been issued.",
"issue_failed": "Failed to issue the cash receipt.",
"reissue_failed": "Reissue failed",
"reissue_failed_help": "Reissuing the cash receipt after the refund failed. Please contact customer support."
}
}
@@ -448,8 +448,46 @@
"close_btn": "닫기",
"estimate_failed": "환불 예상금액 계산에 실패했습니다.",
"cancel_success": "주문이 취소되었습니다.",
"cancel_failed": "주문 취소에 실패했습니다."
"cancel_failed": "주문 취소에 실패했습니다.",
"refund_bank_label": "환불 계좌",
"refund_bank_select": "은행 선택",
"refund_bank_account_placeholder": "계좌번호 (- 없이 입력)",
"refund_bank_holder_placeholder": "예금주",
"refund_bank_required_hint": "입금이 완료된 가상계좌 주문은 환불 계좌가 필요합니다.",
"refund_bank_partial_notice": "셋 중 하나라도 입력하면 나머지도 입력해야 합니다."
}
},
"cash_receipt": {
"title": "현금영수증",
"issue_title": "현금영수증 발급",
"status_issued": "발급완료",
"awaiting_deposit": "입금이 확인되면 발급할 수 있습니다.",
"purpose": "용도",
"purpose_income": "소득공제용",
"purpose_expense": "지출증빙용",
"identifier_type": "발급수단",
"identifier": "식별번호",
"identifier_phone": "휴대폰번호",
"identifier_card": "현금영수증 카드번호",
"identifier_business": "사업자등록번호",
"identifier_placeholder": "- 없이 숫자만 입력",
"immutable_help": "발급 후에는 용도를 변경할 수 없습니다.",
"amount": "금액",
"tax_free_amount": "면세금액",
"issue_number": "발급번호",
"issued_at": "발급일시",
"issue": "현금영수증 발급",
"cancel": "취소",
"cancel_issue": "발급취소",
"reissue": "수동 재발급",
"view_receipt": "영수증 보기",
"history": "발급 이력 ({{count}}건)",
"issue_success": "현금영수증이 발급되었습니다.",
"issue_failed": "현금영수증 발급에 실패했습니다.",
"cancel_success": "현금영수증 발급이 취소되었습니다.",
"cancel_failed": "현금영수증 발급 취소에 실패했습니다.",
"reissue_failed": "재발급 실패",
"reissue_success": "현금영수증이 재발급되었습니다."
}
},
"preset": {
@@ -219,6 +219,23 @@
"restore_on_cancel": "주문 취소 시 재고 복구",
"restore_on_cancel_description": "주문이 취소되면 차감된 재고를 자동으로 복구합니다.",
"restore_on_cancel_hint": "반품/교환 처리 시에도 적용됩니다."
},
"cash_receipt": {
"title": "현금영수증",
"description": "현금영수증 발급 프로바이더와 배송비 과세 방식을 설정합니다.",
"provider_label": "현금영수증 프로바이더",
"provider_none": "(미사용)",
"provider_not_installed": "결제 플러그인을 활성화하면 발급 프로바이더를 선택할 수 있습니다.",
"provider_help": "결제에 사용하는 PG 와 별개로 선택할 수 있습니다.",
"shipping_fee_tax_label": "배송비 과세 방식",
"shipping_fee_tax_proportional": "안분 (기본)",
"shipping_fee_tax_taxable": "전액 과세",
"shipping_fee_tax_follow_main_item": "주된 재화 기준",
"shipping_fee_tax_help": "안분은 과세·면세 상품 금액 비율로 배송비를 나눕니다.",
"self_issue_label": "자진발급 사용",
"self_issue_help": "구매자가 신청하지 않은 무통장 입금 건도 자동으로 발급합니다 (소득공제용 전용).",
"self_issue_mandatory_help": "현금영수증 의무발행 업종은 구매자가 신청하지 않아도 발급 의무가 있습니다. 해당 업종이면 켜세요.",
"self_issue_mandatory_link": "국세청 의무발행 업종 안내"
}
},
"claim": {
@@ -12,5 +12,29 @@
"delivered": "배송완료",
"cancelled": "취소됨",
"refunded": "환불됨"
},
"cash_receipt": {
"title": "현금영수증",
"issue_title": "현금영수증 발급",
"purpose": "용도",
"purpose_income": "소득공제용",
"purpose_expense": "지출증빙용",
"identifier_type": "발급수단",
"identifier": "식별번호",
"identifier_phone": "휴대폰번호",
"identifier_card": "현금영수증 카드번호",
"identifier_business": "사업자등록번호",
"identifier_placeholder": "- 없이 숫자만 입력",
"immutable_help": "발급 후에는 용도를 변경할 수 없습니다.",
"awaiting_deposit": "입금이 확인되면 발급됩니다.",
"none": "발급된 현금영수증이 없습니다.",
"issue": "현금영수증 발급",
"cancel": "취소",
"issued_at": "발급일시",
"view_receipt": "영수증 보기",
"issue_success": "현금영수증이 발급되었습니다.",
"issue_failed": "현금영수증 발급에 실패했습니다.",
"reissue_failed": "재발급 실패",
"reissue_failed_help": "환불에 따른 현금영수증 재발급이 실패했습니다. 고객센터로 문의해 주세요."
}
}
@@ -16,7 +16,8 @@
"batchOrderStatus": "",
"batchCarrierId": "",
"batchTrackingNumber": "",
"activeOrderTab": "order_info"
"activeOrderTab": "order_info",
"expandedCashReceiptHistory": []
},
"transition_overlay": {
"wait_for": [
@@ -64,9 +65,23 @@
"form.intl_state": "{{data.shipping_address?.intl_state ?? ''}}",
"form.intl_postal_code": "{{data.shipping_address?.intl_postal_code ?? ''}}",
"form.delivery_memo": "{{data.delivery_memo ?? ''}}",
"form.admin_memo": "{{data.admin_memo ?? ''}}"
"form.admin_memo": "{{data.admin_memo ?? ''}}",
"_comment_refund_bank": "주문 시 입력된 환불 계좌를 취소 모달에 프리필한다 (관리자가 수정 가능). cancelOrderHandlers 가 이 _local 키를 읽어 refund_bank 로 전송한다.",
"refundBankCode": "{{data.payment?.refund_bank_code ?? ''}}",
"refundBankAccount": "{{data.payment?.refund_bank_account ?? ''}}",
"refundBankHolder": "{{data.payment?.refund_bank_holder ?? ''}}"
}
},
{
"id": "paymentSettings",
"comment": "환불 계좌 은행 목록 (order_settings.banks) — 취소 모달의 은행 Select 가 사용한다. 무통장 입금 계좌와 동일한 목록이다.",
"label_key": "$t:sirsoft-ecommerce.editor.data_source.paymentSettings",
"type": "api",
"endpoint": "/api/modules/sirsoft-ecommerce/settings/payment",
"method": "GET",
"auto_fetch": true,
"auth_required": true
},
{
"id": "active_carriers",
"label_key": "$t:sirsoft-ecommerce.editor.data_source.active_carriers",
@@ -167,6 +182,9 @@
{
"partial": "partials/admin_ecommerce_order_detail/_modal_cancel_order.json"
},
{
"partial": "partials/admin_ecommerce_order_detail/_modal_issue_cash_receipt.json"
},
{
"partial": "partials/admin_ecommerce_order_detail/_modal_reset_guest_password.json"
},
@@ -493,6 +493,161 @@
}
]
},
{
"id": "cancel_refund_bank_section",
"comment": "환불 계좌 (W-4 / #454). 가상계좌·무통장에서만 노출한다 — 카드·간편결제는 원 결제수단으로 환불되므로 계좌가 불필요하다. 가상계좌 입금완료 건은 서버(CancelOrderRequest)가 필수로 강제한다. 주문 시 입력값은 레이아웃 initLocal 이 프리필하며, cancelOrderHandlers 가 _local.refundBank* 를 읽어 전송한다. 신설 블록이므로 responsive.portable 로 작성한다(§6-0).",
"type": "basic",
"name": "Div",
"if": "{{['vbank', 'dbank'].includes(order.data?.payment?.payment_method)}}",
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-body font-medium block mb-2"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.refund_bank_label"
},
{
"comment": "가상계좌 입금완료 건은 필수임을 미리 안내한다 (서버가 422 로 막기 전에)",
"type": "basic",
"name": "P",
"if": "{{order.data?.payment?.payment_method === 'vbank' && order.data?.payment?.payment_status === 'paid'}}",
"props": {
"className": "text-xs text-amber-600 dark:text-amber-400 mb-2"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.refund_bank_required_hint"
},
{
"comment": "은행 / 계좌번호 / 예금주 — 기본 3열, portable 1열 (마크업 구조 변경이므로 responsive 속성 사용)",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-3 gap-3"
},
"responsive": {
"portable": {
"props": {
"className": "grid grid-cols-1 gap-3"
}
}
},
"children": [
{
"id": "cancel_refund_bank_code",
"type": "basic",
"name": "Select",
"props": {
"name": "refund_bank_code",
"value": "{{_local.refundBankCode ?? ''}}",
"options": "{{[{ value: '', label: $t('sirsoft-ecommerce.admin.order.detail.modal.cancel.refund_bank_select') }].concat((paymentSettings.data?.order_settings?.banks ?? []).map(b => ({ value: b.code, label: $localized(b.name) ?? b.code })))}}",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"refundBankCode": "{{$event.target.value}}"
}
}
]
},
{
"id": "cancel_refund_bank_account",
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "refund_bank_account",
"value": "{{_local.refundBankAccount ?? ''}}",
"placeholder": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.refund_bank_account_placeholder",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"refundBankAccount": "{{$event.target.value}}"
}
}
]
},
{
"id": "cancel_refund_bank_holder",
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "refund_bank_holder",
"value": "{{_local.refundBankHolder ?? ''}}",
"placeholder": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.refund_bank_holder_placeholder",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"refundBankHolder": "{{$event.target.value}}"
}
}
]
}
]
},
{
"comment": "서버 검증 오류 — 부분 입력 거부 / 가상계좌 입금완료 필수 (필드별 인라인)",
"type": "basic",
"name": "Div",
"if": "{{!!(_local.cancelValidationErrors?.['refund_bank.bank_code'] || _local.cancelValidationErrors?.['refund_bank.account_number'] || _local.cancelValidationErrors?.['refund_bank.holder'])}}",
"props": {
"className": "mt-1 space-y-0.5"
},
"children": [
{
"type": "basic",
"name": "Span",
"if": "{{!!_local.cancelValidationErrors?.['refund_bank.bank_code']}}",
"props": {
"className": "form-error-xs block"
},
"text": "{{Array.isArray(_local.cancelValidationErrors?.['refund_bank.bank_code']) ? _local.cancelValidationErrors['refund_bank.bank_code'][0] : _local.cancelValidationErrors?.['refund_bank.bank_code']}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!!_local.cancelValidationErrors?.['refund_bank.account_number']}}",
"props": {
"className": "form-error-xs block"
},
"text": "{{Array.isArray(_local.cancelValidationErrors?.['refund_bank.account_number']) ? _local.cancelValidationErrors['refund_bank.account_number'][0] : _local.cancelValidationErrors?.['refund_bank.account_number']}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!!_local.cancelValidationErrors?.['refund_bank.holder']}}",
"props": {
"className": "form-error-xs block"
},
"text": "{{Array.isArray(_local.cancelValidationErrors?.['refund_bank.holder']) ? _local.cancelValidationErrors['refund_bank.holder'][0] : _local.cancelValidationErrors?.['refund_bank.holder']}}"
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-secondary mt-1"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.refund_bank_partial_notice"
}
]
},
{
"comment": "환불 우선순위 라디오 — PG 환불 가능(requires_pg_cancellation)일 때 노출한다. 포인트 사용분이 없으면(total_points_used_amount=0) PG↔포인트 배분 순서가 무의미하므로 노출은 하되 선택 불가(disabled)로 둔다 (A28, 2026-06-22).",
"type": "basic",
@@ -0,0 +1,268 @@
{
"meta": {
"is_partial": true,
"description": "관리자 주문상세 - 현금영수증 발급 모달 (사후 발급 포함)"
},
"id": "modal_issue_cash_receipt",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.issue_title",
"size": "small"
},
"children": [
{
"comment": "모달은 부모와 별도 컨텍스트라 iteration 변수(payment)나 부모 _local 을 참조할 수 없다. 발급 버튼이 열기 전에 _global.adminCashReceiptForm 을 시드하며, 주문 식별은 데이터소스(order) 응답을 직접 참조한다.",
"type": "basic",
"name": "Div",
"props": { "className": "space-y-4" },
"children": [
{
"comment": "용도",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.purpose"
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex flex-row gap-4" },
"responsive": {
"portable": { "props": { "className": "flex flex-col gap-2" } }
},
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" },
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "radio",
"name": "receipt_type",
"value": "income",
"checked": "{{(_global.adminCashReceiptForm?.receipt_type ?? 'income') === 'income'}}",
"className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
},
"actions": [
{
"comment": "소득공제 전환 시 사업자등록번호 선택을 phone 으로 리셋한다 (소득공제에는 사용할 수 없다)",
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"adminCashReceiptForm.receipt_type": "income",
"adminCashReceiptForm.identifier_type": "{{_global.adminCashReceiptForm?.identifier_type === 'business' ? 'phone' : (_global.adminCashReceiptForm?.identifier_type ?? 'phone')}}",
"adminCashReceiptForm.identifier": "{{_global.adminCashReceiptForm?.identifier_type === 'business' ? '' : (_global.adminCashReceiptForm?.identifier ?? '')}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.purpose_income"
}
]
},
{
"type": "basic",
"name": "Label",
"props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" },
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "radio",
"name": "receipt_type",
"value": "expense",
"checked": "{{_global.adminCashReceiptForm?.receipt_type === 'expense'}}",
"className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": { "target": "global", "adminCashReceiptForm.receipt_type": "expense" }
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.purpose_expense"
}
]
}
]
}
]
},
{
"comment": "발급수단 — 지출증빙일 때만 사업자등록번호를 옵션에 포함한다 (D10: 휴대폰은 양쪽 모두 유효)",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier_type"
},
{
"id": "modal_cash_receipt_identifier_type",
"type": "basic",
"name": "Select",
"props": {
"name": "identifier_type",
"value": "{{_global.adminCashReceiptForm?.identifier_type ?? 'phone'}}",
"options": "{{(_global.adminCashReceiptForm?.receipt_type === 'expense' ? [{ value: 'business', label: $t('sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier_business') }] : []).concat([{ value: 'phone', label: $t('sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier_phone') }, { value: 'card', label: $t('sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier_card') }])}}",
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"actions": [
{
"comment": "발급수단이 바뀌면 번호를 비운다 — 형식이 달라 그대로 두면 서버 검증에서 422 가 난다.",
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"adminCashReceiptForm.identifier_type": "{{$event.target.value}}",
"adminCashReceiptForm.identifier": ""
}
}
]
}
]
},
{
"comment": "번호",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier"
},
{
"id": "modal_cash_receipt_identifier",
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "identifier",
"value": "{{_global.adminCashReceiptForm?.identifier ?? ''}}",
"placeholder": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier_placeholder",
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": { "target": "global", "adminCashReceiptForm.identifier": "{{$event.target.value}}" }
}
]
},
{
"comment": "서버 검증 메시지 인라인 노출 (사업자번호 체크섬 · 용도×수단 조합 위반 등)",
"type": "basic",
"name": "Span",
"if": "{{!!_global.adminCashReceiptForm?.error}}",
"props": { "className": "block text-red-500 text-xs mt-1" },
"text": "{{_global.adminCashReceiptForm?.error ?? ''}}"
}
]
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-secondary" },
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.immutable_help"
},
{
"comment": "액션 — 데스크톱 가로 우측정렬 → portable full-width 세로 스택",
"type": "basic",
"name": "Div",
"props": { "className": "flex flex-row justify-end gap-2 pt-2" },
"responsive": {
"portable": { "props": { "className": "flex flex-col gap-2 w-full pt-2" } }
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.cancel",
"actions": [{ "type": "click", "handler": "closeModal" }]
},
{
"id": "modal_cash_receipt_submit",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{!(_global.adminCashReceiptForm?.identifier ?? '')}}"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.issue",
"actions": [
{
"comment": "관리자 API 는 auth_required 를 선언해야 Bearer 토큰이 부착된다 — 미선언 시 401.",
"type": "click",
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/admin/orders/{{order.data?.id}}/cash-receipt",
"auth_required": true,
"params": {
"method": "POST",
"body": {
"receipt_type": "{{_global.adminCashReceiptForm?.receipt_type ?? 'income'}}",
"identifier_type": "{{_global.adminCashReceiptForm?.identifier_type ?? 'phone'}}",
"identifier": "{{_global.adminCashReceiptForm?.identifier ?? ''}}"
}
},
"onSuccess": [
{
"comment": "setState 를 먼저 하고 closeModal 을 나중에 한다 (순서 역전 시 상태가 남는다)",
"handler": "setState",
"params": { "target": "global", "adminCashReceiptForm": null }
},
{ "handler": "closeModal" },
{ "handler": "refetchDataSource", "params": { "dataSourceId": "order" } },
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.issue_success"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"adminCashReceiptForm.error": "{{error.errors?.identifier?.[0] ?? error.message ?? $t('sirsoft-ecommerce.admin.order.detail.cash_receipt.issue_failed')}}"
}
}
]
}
]
}
]
}
]
}
]
}
@@ -27,7 +27,7 @@
"text": "$t:sirsoft-ecommerce.admin.order.detail.payment_info.title"
},
{
"id": "payment_cards",
"id": "payment_cards_{{payIdx}}",
"type": "basic",
"name": "Div",
"iteration": {
@@ -740,6 +740,589 @@
]
}
]
},
{
"id": "payment_cash_receipt_card_{{payIdx}}",
"comment": "현금영수증 카드 (W-3 / #454). 무통장이 아니거나 프로바이더 미설정이면 카드 자체를 렌더하지 않는다. 카드 바깥의 기존 결제정보 본문은 .grid-2col-responsive 등 CSS 유틸을 쓰지만, 신설 카드는 responsive.portable 로 작성한다(액션 버튼의 마크업 구조가 바뀌므로 Tailwind breakpoint 로 처리 불가 — 편집기 디바이스 미리보기가 overrideWidth 를 무시한다).",
"type": "basic",
"name": "Div",
"if": "{{payment?.payment_method === 'dbank' && !!payment?.cash_receipt_provider}}",
"props": {
"className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"comment": "헤더 — 프로바이더 + 발급 상태 배지",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "receipt",
"size": "sm",
"className": "text-gray-700 dark:text-gray-300"
}
},
{
"type": "basic",
"name": "H4",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.title"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-secondary"
},
"text": "{{payment?.cash_receipt_provider ?? ''}}"
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{!!order.data?.cash_receipt}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.status_issued"
}
]
},
{
"id": "payment_cash_receipt_awaiting_{{payIdx}}",
"comment": "입금 전 — 발급 불가 안내. 무통장 주문은 ready 로 생성되고 가상계좌는 waiting_deposit 이다(PaymentStatusEnum::isAwaitingDeposit() 이 둘 다 true). 위 입금확인 버튼(94행)이 이미 같은 두 상태를 함께 검사한다.",
"type": "basic",
"name": "P",
"if": "{{['ready', 'waiting_deposit'].includes(payment?.payment_status)}}",
"props": {
"className": "text-sm text-secondary"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.awaiting_deposit"
},
{
"id": "payment_cash_receipt_issued_{{payIdx}}",
"comment": "발급완료 — 정보 2열(기본) → portable 1열",
"type": "basic",
"name": "Div",
"if": "{{!!order.data?.cash_receipt}}",
"props": {
"className": "grid grid-cols-2 gap-x-4 gap-y-1.5"
},
"responsive": {
"portable": {
"props": {
"className": "grid grid-cols-1 gap-y-1.5"
}
}
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-secondary"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.issued_at"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary"
},
"text": "{{order.data?.cash_receipt?.issued_at_formatted ?? '-'}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-secondary"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.purpose"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary"
},
"text": "{{order.data?.cash_receipt?.receipt_type_label ?? '-'}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-secondary"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary"
},
"text": "{{order.data?.cash_receipt?.identifier_masked ?? '-'}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-secondary"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.amount"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary"
},
"text": "{{order.data?.cash_receipt?.amount_formatted ?? '-'}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-secondary"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.tax_free_amount"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary"
},
"text": "{{order.data?.cash_receipt?.tax_free_amount_formatted ?? '-'}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-secondary"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.issue_number"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary"
},
"text": "{{order.data?.cash_receipt?.issue_number ?? '-'}}"
}
]
}
]
},
{
"id": "payment_cash_receipt_failed_badge_{{payIdx}}",
"comment": "취소 성공 + 재발급 실패 — 경고 배지 + 수동 재발급(관리자 전용). issue_status backing value 는 대문자(FAILED) 라 대소문자 무관 비교한다.",
"type": "basic",
"name": "Div",
"if": "{{!order.data?.cash_receipt && (order.data?.cash_receipts ?? []).length > 0 && String((order.data?.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() === 'failed'}}",
"props": {
"className": "rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 mb-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"size": "sm",
"className": "text-amber-600 dark:text-amber-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-amber-800 dark:text-amber-300"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.reissue_failed"
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-amber-700 dark:text-amber-400 mt-1"
},
"text": "{{(order.data?.cash_receipts ?? [])[0]?.error_message ?? ''}}"
}
]
},
{
"id": "payment_cash_receipt_actions_{{payIdx}}",
"comment": "액션 — 데스크톱 가로 우측정렬 → portable full-width 세로 스택 (§6-0)",
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-row justify-end gap-2 mt-3"
},
"responsive": {
"portable": {
"props": {
"className": "flex flex-col gap-2 w-full mt-3"
}
}
},
"children": [
{
"id": "payment_cash_receipt_view_link_{{payIdx}}",
"type": "basic",
"name": "A",
"if": "{{!!order.data?.cash_receipt?.receipt_url}}",
"props": {
"href": "{{order.data?.cash_receipt?.receipt_url}}",
"target": "_blank",
"rel": "noopener noreferrer",
"className": "inline-flex items-center justify-center gap-1 px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
},
"responsive": {
"portable": {
"props": {
"href": "{{order.data?.cash_receipt?.receipt_url}}",
"target": "_blank",
"rel": "noopener noreferrer",
"className": "inline-flex w-full items-center justify-center gap-1 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
}
}
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.view_receipt"
},
{
"id": "payment_cash_receipt_issue_button_{{payIdx}}",
"comment": "입금완료 + 미발급 + 재발급 실패 이력 없음 → 신규 발급. 실패 이력이 있으면 복구 경로는 수동 재발급이므로 두 버튼을 함께 띄우지 않는다.",
"type": "basic",
"name": "Button",
"if": "{{payment?.payment_status === 'paid' && !order.data?.cash_receipt && String((order.data?.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() !== 'failed'}}",
"props": {
"type": "button",
"className": "px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
},
"responsive": {
"portable": {
"props": {
"type": "button",
"className": "w-full px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
}
}
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.issue",
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"adminCashReceiptForm": "{{ { receipt_type: (payment?.cash_receipt_type ?? 'income'), identifier_type: (payment?.cash_receipt_identifier_type ?? 'phone'), identifier: '', error: null } }}"
}
},
{
"handler": "openModal",
"target": "modal_issue_cash_receipt"
}
]
}
}
]
},
{
"id": "payment_cash_receipt_reissue_button_{{payIdx}}",
"comment": "수동 재발급 — 관리자 전용. 취소 성공 + 재발급 실패 중간 상태 복구 (recoverFailedIssue)",
"type": "basic",
"name": "Button",
"if": "{{!order.data?.cash_receipt && (order.data?.cash_receipts ?? []).length > 0 && String((order.data?.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() === 'failed'}}",
"props": {
"type": "button",
"className": "px-3 py-1.5 text-sm rounded-lg bg-amber-600 hover:bg-amber-700 text-white"
},
"responsive": {
"portable": {
"props": {
"type": "button",
"className": "w-full px-3 py-2 text-sm rounded-lg bg-amber-600 hover:bg-amber-700 text-white"
}
}
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.reissue",
"actions": [
{
"comment": "관리자 API 는 auth_required 를 선언해야 Bearer 토큰이 부착된다 — 미선언 시 401.",
"type": "click",
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/admin/orders/{{order.data?.id}}/cash-receipt/reissue",
"auth_required": true,
"params": {
"method": "POST"
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "order"
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.reissue_success"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message ?? $t('sirsoft-ecommerce.admin.order.detail.cash_receipt.reissue_failed')}}"
}
}
]
}
]
},
{
"id": "payment_cash_receipt_cancel_button_{{payIdx}}",
"comment": "발급취소 — 관리자 전용 (유저 화면에는 이 버튼이 없다)",
"type": "basic",
"name": "Button",
"if": "{{!!order.data?.cash_receipt}}",
"props": {
"type": "button",
"className": "px-3 py-1.5 text-sm rounded-lg border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
},
"responsive": {
"portable": {
"props": {
"type": "button",
"className": "w-full px-3 py-2 text-sm rounded-lg border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
}
}
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.cancel_issue",
"actions": [
{
"comment": "관리자 API 는 auth_required 를 선언해야 Bearer 토큰이 부착된다 — 미선언 시 401.",
"type": "click",
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/admin/orders/{{order.data?.id}}/cash-receipt",
"auth_required": true,
"params": {
"method": "DELETE"
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "order"
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.cancel_success"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message ?? $t('sirsoft-ecommerce.admin.order.detail.cash_receipt.cancel_failed')}}"
}
}
]
}
]
}
]
},
{
"id": "payment_cash_receipt_history_{{payIdx}}",
"comment": "발급 이력 — 발급/취소 전 이력을 최신순으로 보여준다 (국세청 신고 대사용)",
"type": "basic",
"name": "Div",
"if": "{{(order.data?.cash_receipts ?? []).length > 0}}",
"props": {
"className": "mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"id": "payment_cash_receipt_history_toggle_{{payIdx}}",
"comment": "이력은 기본 접힘 — 결제 건별로 독립 토글해야 하므로 _local.expandedCashReceiptHistory 배열에 payIdx 를 넣고 뺀다 (setState params 키는 정적이어야 하므로 값 표현식으로 배열을 조작한다)",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex items-center gap-1 w-full text-left text-xs font-semibold text-secondary mb-1.5 hover:text-gray-900 dark:hover:text-gray-100",
"aria-expanded": "{{(_local.expandedCashReceiptHistory ?? []).includes(payIdx)}}",
"aria-controls": "payment_cash_receipt_history_body_{{payIdx}}"
},
"actions": [
{
"event": "click",
"handler": "setState",
"target": "local",
"params": {
"expandedCashReceiptHistory": "{{(_local.expandedCashReceiptHistory ?? []).includes(payIdx) ? (_local.expandedCashReceiptHistory ?? []).filter(i => i !== payIdx) : [...(_local.expandedCashReceiptHistory ?? []), payIdx]}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{(_local.expandedCashReceiptHistory ?? []).includes(payIdx) ? 'chevron-down' : 'chevron-right'}}",
"size": "xs"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.history|count={{(order.data?.cash_receipts ?? []).length}}"
}
]
},
{
"id": "payment_cash_receipt_history_body_{{payIdx}}",
"type": "basic",
"name": "Div",
"if": "{{(_local.expandedCashReceiptHistory ?? []).includes(payIdx)}}",
"props": {
"className": "space-y-1"
},
"iteration": {
"source": "{{order.data?.cash_receipts ?? []}}",
"item_var": "receipt",
"index_var": "receiptIdx"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between text-xs text-secondary"
},
"children": [
{
"comment": "발급 실패 이력은 issued_at 이 null 이므로 기록 시각(occurred_at_formatted)을 쓴다",
"type": "basic",
"name": "Span",
"text": "{{receipt.occurred_at_formatted ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"text": "{{receipt.transaction_type_label ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"text": "{{receipt.amount_formatted ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "{{String(receipt.issue_status ?? '').toLowerCase() === 'failed' ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}}"
},
"text": "{{receipt.result_label ?? ''}}"
}
]
}
]
}
]
}
]
}
]
}
@@ -85,6 +85,256 @@
}
]
},
{
"id": "cash_receipt_card",
"comment": "현금영수증 설정 (W-5 / #454). 발급 프로바이더는 결제 PG 와 독립 선택한다(KG 로 결제하면서 영수증만 토스로 발급하는 구성 허용 — D5). 배송비 과세 방식은 3택(D3). 신설 블록이므로 responsive.portable 로 작성한다(§6-0).",
"type": "basic",
"name": "Div",
"props": {
"className": "admin-card"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "card-title"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.title"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "card-description"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.description"
},
{
"comment": "프로바이더 Select + 배송비 과세 Select — 기본 2열, portable 1열",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-2 gap-4"
},
"responsive": {
"portable": {
"props": {
"className": "grid grid-cols-1 gap-4"
}
}
},
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium mb-1"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.provider_label"
},
{
"comment": "등록된 프로바이더가 0개면 안내만 노출한다",
"type": "basic",
"name": "Div",
"if": "{{(_local.form?.available_cash_receipt_providers ?? []).length === 0}}",
"props": {
"className": "flex-center gap-2 text-label-subtle"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-info",
"className": "text-sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.provider_not_installed"
}
]
},
{
"id": "cash_receipt_provider_select",
"type": "basic",
"name": "Select",
"if": "{{(_local.form?.available_cash_receipt_providers ?? []).length > 0}}",
"props": {
"value": "{{_local.form?.order_settings?.cash_receipt_provider ?? ''}}",
"className": "w-full",
"options": "{{[{ value: '', label: $t('sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.provider_none') }].concat((_local.form?.available_cash_receipt_providers ?? []).map(p => ({ value: p.id, label: $localized(p.name) ?? p.id })))}}",
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form.order_settings.cash_receipt_provider": "{{$event.target.value || ''}}",
"hasChanges": true
}
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-secondary mt-1"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.provider_help"
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium mb-1"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.shipping_fee_tax_label"
},
{
"id": "shipping_fee_tax_policy_select",
"comment": "배송비 과세 3정책 (D3) — 부가가치세법에 '배송비 50% 과세' 같은 일률 규정은 없다. 상점이 선택한다.",
"type": "basic",
"name": "Select",
"props": {
"value": "{{_local.form?.order_settings?.shipping_fee_tax_policy ?? 'proportional'}}",
"className": "w-full",
"options": "{{[{ value: 'proportional', label: $t('sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.shipping_fee_tax_proportional') }, { value: 'taxable', label: $t('sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.shipping_fee_tax_taxable') }, { value: 'follow_main_item', label: $t('sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.shipping_fee_tax_follow_main_item') }]}}",
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form.order_settings.shipping_fee_tax_policy": "{{$event.target.value}}",
"hasChanges": true
}
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-secondary mt-1"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.shipping_fee_tax_help"
}
]
}
]
},
{
"comment": "자진발급 토글 — 기본 OFF (D14). 의무발행 업종만 켠다.",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"id": "cash_receipt_self_issue_toggle",
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "order_settings.cash_receipt_self_issue",
"checked": "{{_local.form?.order_settings?.cash_receipt_self_issue ?? false}}",
"disabled": "{{_computed.isReadOnly}}",
"className": "w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form.order_settings.cash_receipt_self_issue": "{{$event.target.checked}}",
"hasChanges": true
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.self_issue_label"
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-secondary mt-1 ml-6"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.self_issue_help"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-secondary mt-1 ml-6"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.self_issue_mandatory_help"
},
{
"id": "cash_receipt_mandatory_industry_link",
"type": "basic",
"name": "A",
"props": {
"href": "https://www.nts.go.kr/nts/cm/cntnts/cntntsView.do?cntntsId=7796&mi=2471",
"target": "_blank",
"rel": "noopener noreferrer",
"className": "inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-1 ml-6"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.self_issue_mandatory_link"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "external-link",
"size": "sm"
}
}
]
}
]
}
]
},
{
"id": "payment_methods_card",
"type": "basic",
@@ -42,6 +42,26 @@ enum CashReceiptIdentifierType: string
return array_column(self::cases(), 'value');
}
/**
* 식별번호를 마스킹합니다 (뒤 4자리만 노출).
*
* 식별번호의 형태를 아는 것은 이 Enum 의 책임이므로 마스킹 규칙도 여기에 둔다.
* 주문 생성 시점(신청 저장)과 발급 시점(이력 기록)이 동일한 마스킹을 써야 하므로 SSoT 로 유지한다.
*
* @param string $identifier 식별번호 원본
* @return string 마스킹된 식별번호
*/
public static function mask(string $identifier): string
{
$length = strlen($identifier);
if ($length <= 4) {
return str_repeat('*', $length);
}
return str_repeat('*', $length - 4).substr($identifier, -4);
}
/**
* 유효한 값인지 확인합니다.
*
@@ -53,6 +53,9 @@ class EcommerceSettingsController extends AdminBaseController
$settings = $this->appendClaimReasonsToSettings($settings);
$settings = $this->appendMileageNotificationChannelsToSettings($settings);
$settings['available_pg_providers'] = $this->settingsService->getRegisteredPgProviders();
// 현금영수증 발급 프로바이더 후보 — 결제 플러그인이 훅으로 자신을 등록한다.
// 발급 PG 와 결제 PG 는 독립 선택이므로 목록도 별도로 내린다 (KG 결제 + 토스 발급 등).
$settings['available_cash_receipt_providers'] = $this->settingsService->getRegisteredCashReceiptProviders();
$settings['abilities'] = [
'can_update' => PermissionHelper::check('sirsoft-ecommerce.settings.update', request()->user()),
];
@@ -167,6 +170,7 @@ class EcommerceSettingsController extends AdminBaseController
$updatedSettings = $this->appendClaimReasonsToSettings($updatedSettings);
$updatedSettings = $this->appendMileageNotificationChannelsToSettings($updatedSettings);
$updatedSettings['available_pg_providers'] = $this->settingsService->getRegisteredPgProviders();
$updatedSettings['available_cash_receipt_providers'] = $this->settingsService->getRegisteredCashReceiptProviders();
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
@@ -338,6 +338,7 @@ class OrderController extends AdminBaseController
cancelledBy: $cancelledBy,
cancelPg: $request->shouldCancelPg(),
refundPriority: $request->getRefundPriority(),
refundBankInfo: $request->getRefundBankInfo(),
);
} else {
$result = $this->cancellationService->cancelOrderOptions(
@@ -348,6 +349,7 @@ class OrderController extends AdminBaseController
cancelledBy: $cancelledBy,
cancelPg: $request->shouldCancelPg(),
refundPriority: $request->getRefundPriority(),
refundBankInfo: $request->getRefundBankInfo(),
);
}
@@ -91,7 +91,9 @@ trait HandlesOrderCreation
shippingMemo: $request->input('shipping_memo'),
depositorName: $request->input('depositor_name'),
dbankInfo: $request->getDbankInfo(),
guestLookupPassword: $request->getGuestLookupPassword()
guestLookupPassword: $request->getGuestLookupPassword(),
cashReceiptInfo: $request->getCashReceiptInfo(),
refundBankInfo: $request->getRefundBankInfo()
);
$order->load(['options', 'payment', 'shippingAddress']);
@@ -8,9 +8,12 @@ use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\ClaimReason;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum;
use Modules\Sirsoft\Ecommerce\Models\ClaimReason;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
/**
* 주문 취소 요청 (관리자)
@@ -22,7 +25,7 @@ class CancelOrderRequest extends FormRequest
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* @return bool
* @return bool 항상 true (권한 검사는 permission 미들웨어가 담당)
*/
public function authorize(): bool
{
@@ -32,7 +35,7 @@ class CancelOrderRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array
* @return array 검증 규칙 배열
*/
public function rules(): array
{
@@ -45,13 +48,40 @@ class CancelOrderRequest extends FormRequest
'items.*.cancel_quantity' => ['required_with:items', 'integer', 'min:1'],
'cancel_pg' => ['nullable', 'boolean'],
'refund_priority' => ['sometimes', 'string', 'in:'.implode(',', RefundPriorityEnum::values())],
// 환불 계좌 — 표시/필수 조건은 결제수단·입금상태에 따라 다르므로 withValidator 가 판정한다.
'refund_bank.bank_code' => ['nullable', 'string', 'max:10'],
'refund_bank.account_number' => ['nullable', 'string', 'max:50'],
'refund_bank.holder' => ['nullable', 'string', 'max:50'],
];
}
/**
* 환불 계좌 정보를 반환합니다. (미입력이면 null)
*
* 주문 시 입력된 계좌가 있으면 관리자가 수정할 수 있으며, 여기서 반환된 값이 결제행을 갱신합니다.
*
* @return array{bank_code: string, account_number: string, holder: string}|null 환불 계좌 (미입력 시 null)
*/
public function getRefundBankInfo(): ?array
{
$bankCode = $this->input('refund_bank.bank_code');
if (blank($bankCode)) {
return null;
}
return [
'bank_code' => (string) $bankCode,
'account_number' => (string) $this->input('refund_bank.account_number'),
'holder' => (string) $this->input('refund_bank.holder'),
];
}
/**
* 검증 필드의 사용자 표시명
*
* @return array
* @return array 필드명 → 표시명 매핑
*/
public function attributes(): array
{
@@ -64,8 +94,7 @@ class CancelOrderRequest extends FormRequest
/**
* 추가 검증 로직
*
* @param Validator $validator
* @return void
* @param Validator $validator 검증기
*/
protected function withValidator(Validator $validator): void
{
@@ -99,6 +128,9 @@ class CancelOrderRequest extends FormRequest
return;
}
// 환불 계좌 조건부 검증 (결제수단·입금상태 기준)
$this->validateRefundBank($validator, $order);
// 부분취소 시 items 검증
if ($this->input('type') === 'partial') {
$this->validateCancelItems($validator, $order);
@@ -106,12 +138,92 @@ class CancelOrderRequest extends FormRequest
});
}
/**
* 환불 계좌를 조건부로 검증합니다.
*
* 가상계좌 입금완료 건은 PG 환불 API 가 환불받을 계좌를 필수로 요구하므로 미입력을 거부합니다.
* 입금 전 가상계좌는 PG 가 계좌를 요구하지 않고, 무통장은 관리자가 수동 이체하므로 선택 입력입니다.
* 카드·간편결제는 원 결제수단으로 환불되므로 계좌 자체가 불필요합니다.
*
* 부분 입력(3필드 중 일부만)은 결제수단과 무관하게 거부합니다 — 그 상태로는 환불이 불가능합니다.
*
* @param Validator $validator 검증기
* @param Order $order 취소 대상 주문
*/
protected function validateRefundBank(Validator $validator, Order $order): void
{
$fields = ['bank_code', 'account_number', 'holder'];
$filled = array_filter(
$fields,
fn (string $field): bool => filled($this->input("refund_bank.{$field}"))
);
// 부분 입력 거부 (전부 비었거나 전부 채워졌거나)
if ($filled !== [] && count($filled) !== count($fields)) {
foreach (array_diff($fields, $filled) as $missing) {
$validator->errors()->add(
"refund_bank.{$missing}",
__('sirsoft-ecommerce::validation.order.refund_bank_required_with')
);
}
return;
}
if ($filled !== []) {
return;
}
// 미입력 — 가상계좌 입금완료 건만 필수. 주문 시 입력된 계좌가 있으면 그것을 쓰므로 통과.
$order->loadMissing('payment');
$payment = $order->payment;
if (! $payment || ! $this->requiresRefundBank($payment)) {
return;
}
if (filled($payment->refund_bank_code)) {
return;
}
foreach ($fields as $field) {
$validator->errors()->add(
"refund_bank.{$field}",
__('sirsoft-ecommerce::validation.order.refund_bank_required_for_vbank')
);
}
}
/**
* 환불 계좌가 필수인 결제인지 판정합니다.
*
* @param OrderPayment $payment 결제 정보
* @return bool 필수 여부
*/
protected function requiresRefundBank(OrderPayment $payment): bool
{
$method = $payment->payment_method instanceof PaymentMethodEnum
? $payment->payment_method
: PaymentMethodEnum::tryFrom((string) $payment->payment_method);
if ($method !== PaymentMethodEnum::VBANK) {
return false;
}
$status = $payment->payment_status instanceof PaymentStatusEnum
? $payment->payment_status
: PaymentStatusEnum::tryFrom((string) $payment->payment_status);
// 입금 전(waiting_deposit)이면 PG 가 환불받을 계좌를 요구하지 않는다.
return $status !== null && ! $status->isAwaitingDeposit();
}
/**
* 취소 아이템 목록을 검증합니다.
*
* @param Validator $validator
* @param Order $order
* @return void
* @param Validator $validator 검증기
* @param Order $order 취소 대상 주문
*/
protected function validateCancelItems(Validator $validator, Order $order): void
{
@@ -155,8 +267,6 @@ class CancelOrderRequest extends FormRequest
/**
* 검증 실패 시 응답 커스터마이징
*
* @param Validator $validator
* @return void
*
* @throws ValidationException
*/
@@ -172,7 +282,7 @@ class CancelOrderRequest extends FormRequest
/**
* 전체취소 여부를 반환합니다.
*
* @return bool
* @return bool 전체취소면 true
*/
public function isFullCancel(): bool
{
@@ -182,7 +292,7 @@ class CancelOrderRequest extends FormRequest
/**
* 취소 사유 코드를 반환합니다.
*
* @return string|null
* @return string|null 취소 사유 코드
*/
public function getReason(): ?string
{
@@ -192,7 +302,7 @@ class CancelOrderRequest extends FormRequest
/**
* 상세 취소 사유를 반환합니다.
*
* @return string|null
* @return string|null 상세 취소 사유 (미입력 시 null)
*/
public function getReasonDetail(): ?string
{
@@ -212,7 +322,7 @@ class CancelOrderRequest extends FormRequest
/**
* PG 결제 취소 여부를 반환합니다.
*
* @return bool
* @return bool PG 결제도 함께 취소하면 true
*/
public function shouldCancelPg(): bool
{
@@ -222,7 +332,7 @@ class CancelOrderRequest extends FormRequest
/**
* 환불 우선순위를 반환합니다.
*
* @return RefundPriorityEnum
* @return RefundPriorityEnum 환불 우선순위
*/
public function getRefundPriority(): RefundPriorityEnum
{
@@ -5,6 +5,8 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin;
use App\Rules\LocaleRequiredTranslatable;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\ShippingFeeTaxPolicy;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
@@ -245,6 +247,11 @@ class StoreEcommerceSettingsRequest extends FormRequest
// order_settings 섹션
'order_settings' => ['sometimes', 'array'],
'order_settings.default_pg_provider' => ['nullable', 'string', 'max:50'],
// 현금영수증 — 발급 프로바이더는 결제 PG 와 독립 선택한다 (빈 문자열 = 미사용).
// 규칙을 명시하지 않으면 validated() 가 키를 떨궈 저장이 조용히 무효화된다.
'order_settings.cash_receipt_provider' => ['nullable', 'string', 'max:50'],
'order_settings.cash_receipt_self_issue' => ['nullable', 'boolean'],
'order_settings.shipping_fee_tax_policy' => ['nullable', 'string', 'in:'.implode(',', ShippingFeeTaxPolicy::values())],
'order_settings.payment_methods' => ['nullable', 'array'],
'order_settings.payment_methods.*.id' => ['required_with:order_settings.payment_methods', 'string', 'max:50'],
'order_settings.payment_methods.*.pg_provider' => ['nullable', 'string', 'max:50'],
@@ -3,9 +3,13 @@
namespace Modules\Sirsoft\Ecommerce\Http\Requests\Public;
use App\Extension\HookManager;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Rules\CashReceiptIdentifier;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
/**
@@ -81,6 +85,33 @@ class CreateOrderRequest extends FormRequest
// 배송지 저장
'save_shipping_address' => 'nullable|boolean',
// 현금영수증 신청 (무통장입금 전용 — 신청 시에만 하위 3키 필수)
'cash_receipt_requested' => 'nullable|boolean',
'cash_receipt_type' => [
'required_if:cash_receipt_requested,true',
'nullable',
'string',
Rule::in(CashReceiptType::values()),
],
'cash_receipt_identifier_type' => [
'required_if:cash_receipt_requested,true',
'nullable',
'string',
Rule::in(CashReceiptIdentifierType::values()),
],
'cash_receipt_identifier' => [
'required_if:cash_receipt_requested,true',
'nullable',
'string',
'max:30',
new CashReceiptIdentifier($this->resolveCashReceiptType(), $this->resolveCashReceiptIdentifierType()),
],
// 환불 계좌 (선택) — 부분 입력 금지는 withValidator 의 required_with 로 처리
'refund_bank.bank_code' => 'nullable|string|max:10',
'refund_bank.account_number' => 'nullable|string|max:50',
'refund_bank.holder' => 'nullable|string|max:50',
];
// 주문자 이메일은 회원/비회원 분기 (비회원은 알림 수신 통로가 이메일뿐 → 필수)
@@ -92,6 +123,39 @@ class CreateOrderRequest extends FormRequest
return HookManager::applyFilters('sirsoft-ecommerce.order.create_validation_rules', $rules, $this);
}
/**
* 검증기 확장 — 환불 계좌의 부분 입력을 거부합니다.
*
* 3필드는 모두 선택 입력이지만 일부만 채우면 환불 자체가 불가능합니다.
* 계좌번호만 있고 예금주가 없으면 PG 환불 API 가 거부하고, 무통장은 관리자가 이체할 수 없습니다.
* 따라서 "전부 비었거나 전부 채워졌거나" 둘 중 하나만 허용합니다.
*
* @param Validator $validator 검증기
*/
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$fields = ['bank_code', 'account_number', 'holder'];
$filled = array_filter(
$fields,
fn (string $field): bool => filled($this->input("refund_bank.{$field}"))
);
// 전부 비었으면 미입력 (허용), 전부 채워졌으면 완전 입력 (허용)
if ($filled === [] || count($filled) === count($fields)) {
return;
}
foreach (array_diff($fields, $filled) as $missing) {
$validator->errors()->add(
"refund_bank.{$missing}",
__('sirsoft-ecommerce::validation.order.refund_bank_required_with')
);
}
});
}
/**
* 주문자 이메일 검증 규칙을 반환합니다.
*
@@ -180,6 +244,13 @@ class CreateOrderRequest extends FormRequest
'dbank.account_number.required_if' => __('sirsoft-ecommerce::validation.order.dbank_account_number_required'),
'dbank.account_holder.required_if' => __('sirsoft-ecommerce::validation.order.dbank_account_holder_required'),
// 현금영수증 신청
'cash_receipt_type.required_if' => __('sirsoft-ecommerce::validation.order.cash_receipt_type_required'),
'cash_receipt_type.in' => __('sirsoft-ecommerce::validation.order.cash_receipt_type_invalid'),
'cash_receipt_identifier_type.required_if' => __('sirsoft-ecommerce::validation.order.cash_receipt_identifier_type_required'),
'cash_receipt_identifier_type.in' => __('sirsoft-ecommerce::validation.order.cash_receipt_identifier_type_invalid'),
'cash_receipt_identifier.required_if' => __('sirsoft-ecommerce::validation.order.cash_receipt_identifier_required'),
// 비회원 조회 비밀번호
'guest_lookup_password.required' => __('sirsoft-ecommerce::validation.order.guest_lookup_password_required'),
'guest_lookup_password.min' => __('sirsoft-ecommerce::validation.order.guest_lookup_password_min'),
@@ -246,4 +317,87 @@ class CreateOrderRequest extends FormRequest
return is_string($password) && $password !== '' ? $password : null;
}
/**
* 현금영수증 신청 정보를 반환합니다. (미신청이거나 무통장이 아니면 null)
*
* 식별번호는 하이픈·공백이 제거된 원본입니다. 저장 시점에 마스킹본과 암호문으로 분리되며,
* 원본은 응답·로그에 노출하지 않습니다.
*
* 발급 대상은 무통장(dbank)뿐이므로(CashReceiptService 가 재차 차단) 그 외 결제수단에서는
* 신청 정보를 반환하지 않는다. 발급될 수 없는 주문에 식별번호 암호문을 남기지 않기 위함이다.
*
* @return array{type: CashReceiptType, identifier_type: CashReceiptIdentifierType, identifier: string}|null 신청 정보 (미신청·비무통장 시 null)
*/
public function getCashReceiptInfo(): ?array
{
if ($this->input('payment_method') !== PaymentMethodEnum::DBANK->value) {
return null;
}
if (! $this->boolean('cash_receipt_requested')) {
return null;
}
$type = $this->resolveCashReceiptType();
$identifierType = $this->resolveCashReceiptIdentifierType();
$identifier = $this->input('cash_receipt_identifier');
// required_if 를 통과했다면 셋 다 존재한다. 방어적으로 한 번 더 확인한다.
if ($type === null || $identifierType === null || ! is_string($identifier)) {
return null;
}
return [
'type' => $type,
'identifier_type' => $identifierType,
'identifier' => CashReceiptIdentifier::normalize($identifier),
];
}
/**
* 환불 계좌 정보를 반환합니다. (미입력이면 null)
*
* withValidator 가 부분 입력을 거부하므로, 값이 있으면 3필드가 모두 채워져 있습니다.
*
* @return array{bank_code: string, account_number: string, holder: string}|null 환불 계좌 (미입력 시 null)
*/
public function getRefundBankInfo(): ?array
{
$bankCode = $this->input('refund_bank.bank_code');
if (blank($bankCode)) {
return null;
}
return [
'bank_code' => (string) $bankCode,
'account_number' => (string) $this->input('refund_bank.account_number'),
'holder' => (string) $this->input('refund_bank.holder'),
];
}
/**
* 검증 전 입력에서 현금영수증 발급 용도를 해석합니다. (Rule 생성자 주입용)
*
* @return CashReceiptType|null 해석된 용도 (미해석 시 null)
*/
private function resolveCashReceiptType(): ?CashReceiptType
{
$value = $this->input('cash_receipt_type');
return is_string($value) ? CashReceiptType::tryFrom($value) : null;
}
/**
* 검증 전 입력에서 현금영수증 식별번호 종류를 해석합니다. (Rule 생성자 주입용)
*
* @return CashReceiptIdentifierType|null 해석된 종류 (미해석 시 null)
*/
private function resolveCashReceiptIdentifierType(): ?CashReceiptIdentifierType
{
$value = $this->input('cash_receipt_identifier_type');
return is_string($value) ? CashReceiptIdentifierType::tryFrom($value) : null;
}
}
@@ -28,6 +28,8 @@ class CashReceiptResource extends BaseApiResource
'id' => $this->id,
'provider' => $this->provider,
'transaction_type' => $this->transaction_type,
// 관리자 발급 이력 표는 원시값(issue/cancel, FAILED)이 아니라 표시명을 노출한다.
'transaction_type_label' => $this->transaction_type?->label(),
'receipt_type' => $this->receipt_type,
'receipt_type_label' => $this->receipt_type?->label(),
'amount' => $this->roundToOrderCurrency($this->amount),
@@ -38,11 +40,19 @@ class CashReceiptResource extends BaseApiResource
'receipt_url' => $this->receipt_url,
'issue_number' => $this->issue_number,
'issue_status' => $this->issue_status,
'issue_status_label' => $this->issue_status?->label(),
// 이력 표의 결과 열 — 발급/취소 행이 같은 열을 공유하므로 중립 표현을 쓴다.
// issue_status_label 은 발급 관점 표현("발급 완료")이라 취소 행에 쓰면 오해를 부른다.
'result_label' => $this->issue_status
? __('sirsoft-ecommerce::cash_receipt.result_status.'.strtolower($this->issue_status->value))
: null,
'error_code' => $this->error_code,
'error_message' => $this->error_message,
// 머신 ISO8601 — 표시는 issued_at_formatted 사용
'issued_at' => $this->issued_at?->toIso8601String(), // audit:allow datetime-display-user-timezone reason: machine ISO8601, display uses issued_at_formatted sibling
'issued_at_formatted' => $this->formatDateTimeStringForUser($this->issued_at),
// 이력 표의 시각 열 — 발급 실패 이력은 issued_at 이 null 이므로 기록 시각으로 대체한다.
'occurred_at_formatted' => $this->formatDateTimeStringForUser($this->issued_at ?? $this->created_at),
];
}
}
@@ -6,6 +6,7 @@ use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
/**
* 주문 결제 정보 리소스
@@ -80,12 +81,25 @@ class OrderPaymentResource extends BaseApiResource
'deposit_due_at' => $this->deposit_due_at?->toIso8601String(), // audit:allow datetime-display-user-timezone reason: machine ISO8601, display uses deposit_due_at_formatted sibling
'deposit_due_at_formatted' => $this->formatDateTimeStringForUser($this->deposit_due_at),
// 환불 계좌 — 주문 시 입력된 계좌. 관리자 취소 모달이 이 값을 프리필하고,
// PG 환불(refundReceiveAccount 구성)이 같은 컬럼을 읽는다.
'refund_bank_code' => $this->refund_bank_code,
'refund_bank_name' => $this->refund_bank_name,
'refund_bank_account' => $this->refund_bank_account,
'refund_bank_holder' => $this->refund_bank_holder,
// 현금영수증 (cash_receipt_identifier 는 마스킹 값 — 원본/암호문은 노출하지 않는다)
'is_cash_receipt_requested' => (bool) $this->is_cash_receipt_requested,
'is_cash_receipt_issued' => (bool) $this->is_cash_receipt_issued,
'cash_receipt_type' => $this->cash_receipt_type,
// 레거시 값(income_deduction 등)도 표시명으로 해석되도록 Enum 의 fromLegacy 를 경유한다.
'cash_receipt_type_label' => $this->getCashReceiptType()?->label(),
'cash_receipt_identifier_type' => $this->cash_receipt_identifier_type,
'cash_receipt_identifier' => $this->cash_receipt_identifier,
// 현재 설정된 발급 프로바이더 (미설정이면 null).
// 주문상세의 현금영수증 카드가 이 리소스만 보고 렌더 여부를 판정할 수 있도록 함께 내린다
// (프로바이더는 상점 전역 설정이지만 카드는 결제 정보 안에 있다).
'cash_receipt_provider' => app(EcommerceSettingsService::class)->getCashReceiptProvider(),
// 머신 ISO8601 — 표시는 cash_receipt_issued_at_formatted 사용
'cash_receipt_issued_at' => $this->cash_receipt_issued_at?->toIso8601String(), // audit:allow datetime-display-user-timezone reason: machine ISO8601, display uses cash_receipt_issued_at_formatted sibling
'cash_receipt_issued_at_formatted' => $this->formatDateTimeStringForUser($this->cash_receipt_issued_at),
@@ -69,4 +69,25 @@ interface OrderPaymentRepositoryInterface
* @return bool 암호문 존재 여부
*/
public function hasCashReceiptIdentifier(OrderPayment $payment): bool;
/**
* 환불 계좌 정보를 갱신합니다.
*
* 주문 취소 시점에 관리자가 입력·수정한 계좌를 반영한다. PG 환불(executePgRefund)이
* 이 컬럼을 읽어 refundReceiveAccount 를 구성하므로 환불 실행 전에 갱신되어야 한다.
*
* @param OrderPayment $payment 결제 모델
* @param string $bankCode 은행코드
* @param string $bankName 은행명 (표시용)
* @param string $accountNumber 계좌번호
* @param string $holder 예금주
* @return OrderPayment 갱신된 결제 모델
*/
public function updateRefundBank(
OrderPayment $payment,
string $bankCode,
string $bankName,
string $accountNumber,
string $holder,
): OrderPayment;
}
@@ -89,4 +89,24 @@ class OrderPaymentRepository implements OrderPaymentRepositoryInterface
{
return $payment->getRawOriginal('cash_receipt_identifier_encrypted') !== null;
}
/**
* {@inheritDoc}
*/
public function updateRefundBank(
OrderPayment $payment,
string $bankCode,
string $bankName,
string $accountNumber,
string $holder,
): OrderPayment {
$payment->update([
'refund_bank_code' => $bankCode,
'refund_bank_name' => $bankName,
'refund_bank_account' => $accountNumber,
'refund_bank_holder' => $holder,
]);
return $payment;
}
}
@@ -167,6 +167,9 @@ class OrderRepository implements OrderRepositoryInterface
'shippings',
// 취소 이력 — 주문상세 화면의 취소 사유/일시 표시용 (최근 취소 먼저)
'cancels' => fn ($q) => $q->latest('cancelled_at'),
// 현금영수증 이력 — 주문상세의 발급 카드가 활성 영수증 1건과 전체 이력을 함께 표시한다.
// 미로드 시 OrderResource 의 whenLoaded 가드로 응답에 키 자체가 나타나지 않는다.
'cashReceipts',
])
->find($id);
}
@@ -182,6 +185,8 @@ class OrderRepository implements OrderRepositoryInterface
'options',
'shippingAddress',
'payment',
// 비회원 주문상세도 현금영수증 카드를 렌더하므로 함께 로드한다.
'cashReceipts',
])
->where('order_number', $orderNumber)
->first();
@@ -622,18 +622,15 @@ class CashReceiptService
/**
* 식별번호를 마스킹합니다 (뒤 4자리만 노출).
*
* 마스킹 규칙의 SSoT 는 Enum 이다 — 주문 생성 시점(신청 저장)과 발급 시점(이력 기록)이
* 동일한 마스킹을 써야 하므로 규칙을 복제하지 않고 위임한다.
*
* @param string $identifier 식별번호 원본
* @return string 마스킹된 식별번호
*/
private function maskIdentifier(string $identifier): string
{
$length = strlen($identifier);
if ($length <= 4) {
return str_repeat('*', $length);
}
return str_repeat('*', $length - 4).substr($identifier, -4);
return CashReceiptIdentifierType::mask($identifier);
}
/**
@@ -741,6 +741,27 @@ class EcommerceSettingsService implements ModuleSettingsInterface
return ShippingFeeTaxPolicy::fromValueOrDefault(is_string($value) ? $value : null);
}
/**
* 은행코드로 은행명을 조회합니다.
*
* 환경설정의 은행 목록(order_settings.banks)에서 현재 로케일의 표시명을 찾습니다.
* 무통장 입금 계좌와 환불 계좌가 같은 목록을 공유하므로 여기서 단일 조회 지점을 제공합니다.
*
* @param string $bankCode 은행코드
* @return string 은행명 (목록에 없는 코드는 코드 그대로)
*/
public function resolveBankName(string $bankCode): string
{
$banks = $this->getSetting('order_settings.banks');
$bank = collect(is_array($banks) ? $banks : [])->firstWhere('code', $bankCode);
if (! $bank) {
return $bankCode;
}
return $bank['name'][app()->getLocale()] ?? $bank['name']['ko'] ?? $bankCode;
}
/**
* 특정 결제수단의 설정을 조회합니다.
*
@@ -29,6 +29,7 @@ use Modules\Sirsoft\Ecommerce\Models\OrderRefundOption;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderCancelOptionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderCancelRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundOptionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface;
@@ -55,6 +56,7 @@ class OrderCancellationService
* @param OrderRefundRepositoryInterface $orderRefundRepository 주문 환불 Repository
* @param OrderRefundOptionRepositoryInterface $orderRefundOptionRepository 주문 환불 옵션 Repository
* @param CashReceiptService $cashReceiptService 현금영수증 발급/취소 서비스
* @param OrderPaymentRepositoryInterface $orderPaymentRepository 주문 결제 Repository
*/
public function __construct(
protected OrderAdjustmentService $adjustmentService,
@@ -69,8 +71,39 @@ class OrderCancellationService
protected OrderRefundRepositoryInterface $orderRefundRepository,
protected OrderRefundOptionRepositoryInterface $orderRefundOptionRepository,
protected CashReceiptService $cashReceiptService,
protected OrderPaymentRepositoryInterface $orderPaymentRepository,
) {}
/**
* 관리자가 취소 모달에서 입력한 환불 계좌를 결제행에 반영합니다.
*
* 미입력이면 기존 값(주문 시 입력분)을 그대로 둔다 — 취소 요청이 계좌를 지우지 않는다.
* 가상계좌 입금완료 건의 미입력은 CancelOrderRequest 가 이미 422 로 막는다.
*
* @param Order $order 대상 주문
* @param array|null $refundBankInfo 환불 계좌 (bank_code/account_number/holder)
*/
protected function applyRefundBank(Order $order, ?array $refundBankInfo): void
{
if ($refundBankInfo === null) {
return;
}
$order->loadMissing('payment');
if (! $order->payment) {
return;
}
$this->orderPaymentRepository->updateRefundBank(
$order->payment,
$refundBankInfo['bank_code'],
$this->settingsService->resolveBankName($refundBankInfo['bank_code']),
$refundBankInfo['account_number'],
$refundBankInfo['holder'],
);
}
/**
* 취소 확정 후 현금영수증을 현재 금액에 맞춰 동기화합니다.
*
@@ -111,6 +144,7 @@ class OrderCancellationService
* @param int|null $cancelledBy 취소 요청자 ID
* @param bool $cancelPg PG 결제 취소 여부
* @param RefundPriorityEnum $refundPriority 환불 우선순위
* @param array|null $refundBankInfo 환불 계좌 (bank_code/account_number/holder, 미입력 시 null)
* @return CancellationResult 취소 결과
*
* @throws \Exception 취소 불가 또는 PG 환불 실패 시
@@ -122,6 +156,7 @@ class OrderCancellationService
?int $cancelledBy = null,
bool $cancelPg = true,
RefundPriorityEnum $refundPriority = RefundPriorityEnum::PG_FIRST,
?array $refundBankInfo = null,
): CancellationResult {
$order->loadMissing(['options', 'payment', 'shippings']);
@@ -146,6 +181,7 @@ class OrderCancellationService
cancelledBy: $cancelledBy,
cancelPg: $cancelPg,
refundPriority: $refundPriority,
refundBankInfo: $refundBankInfo,
);
}
@@ -159,6 +195,7 @@ class OrderCancellationService
* @param int|null $cancelledBy 취소 요청자 ID
* @param bool $cancelPg PG 결제 취소 여부
* @param RefundPriorityEnum $refundPriority 환불 우선순위
* @param array|null $refundBankInfo 환불 계좌 (bank_code/account_number/holder, 미입력 시 null)
* @return CancellationResult 취소 결과
*
* @throws \Exception 취소 불가 또는 PG 환불 실패 시
@@ -171,6 +208,7 @@ class OrderCancellationService
?int $cancelledBy = null,
bool $cancelPg = true,
RefundPriorityEnum $refundPriority = RefundPriorityEnum::PG_FIRST,
?array $refundBankInfo = null,
): CancellationResult {
$order->loadMissing(['options', 'payment', 'shippings']);
@@ -188,6 +226,7 @@ class OrderCancellationService
cancelledBy: $cancelledBy,
cancelPg: $cancelPg,
refundPriority: $refundPriority,
refundBankInfo: $refundBankInfo,
);
}
@@ -220,6 +259,7 @@ class OrderCancellationService
* @param int|null $cancelledBy 요청자 ID
* @param bool $cancelPg PG 취소 여부
* @param RefundPriorityEnum $refundPriority 환불 우선순위
* @param array|null $refundBankInfo 환불 계좌 (bank_code/account_number/holder, 미입력 시 null)
* @return CancellationResult 취소 결과
*
* @throws \Exception
@@ -233,11 +273,16 @@ class OrderCancellationService
?int $cancelledBy,
bool $cancelPg,
RefundPriorityEnum $refundPriority = RefundPriorityEnum::PG_FIRST,
?array $refundBankInfo = null,
): CancellationResult {
// ① 검증
$this->validateCancellable($order);
$this->validateCancelItems($order, $cancelItems);
// 환불 계좌 갱신 — PG 환불(executePgRefund)이 이 컬럼을 읽어 refundReceiveAccount 를
// 구성하므로 환불 실행 전에 반영해야 한다. 관리자가 취소 모달에서 입력/수정한 값이다.
$this->applyRefundBank($order, $refundBankInfo);
// 결제 취소 전 본인인증(IDV) 정책 가드 지점 (관리자/사용자 — payment.cancel purpose).
// EnforceIdentityPolicyListener 가 'sirsoft-ecommerce.payment.cancel' 정책이 활성이고
// grace 만료 시 IdentityVerificationRequiredException(428) 을 throw 한다.
@@ -12,6 +12,7 @@ use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
use Modules\Sirsoft\Ecommerce\DTO\ShippingAddress;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
use Modules\Sirsoft\Ecommerce\Enums\DeliveryMemoPresetEnum;
use Modules\Sirsoft\Ecommerce\Enums\DeviceTypeEnum;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
@@ -86,6 +87,8 @@ class OrderProcessingService
* @param string|null $depositorName 입금자명 (무통장입금 시)
* @param array|null $dbankInfo 무통장 수동입금 정보 (dbank 결제 시)
* @param string|null $guestLookupPassword 비회원 조회 비밀번호 평문 (해시로 저장, 회원 주문은 null)
* @param array|null $cashReceiptInfo 현금영수증 신청 정보 (type/identifier_type/identifier, 미신청 시 null)
* @param array|null $refundBankInfo 환불 계좌 정보 (bank_code/account_number/holder, 미입력 시 null)
* @return Order 생성된 주문
*
* @throws OrderAmountChangedException 재계산 금액 변동 시
@@ -101,7 +104,9 @@ class OrderProcessingService
?string $shippingMemo = null,
?string $depositorName = null,
?array $dbankInfo = null,
?string $guestLookupPassword = null
?string $guestLookupPassword = null,
?array $cashReceiptInfo = null,
?array $refundBankInfo = null
): Order {
// 생성 전 훅
HookManager::doAction('sirsoft-ecommerce.order.before_create', $tempOrder, $ordererInfo, $shippingInfo, $paymentMethod);
@@ -169,7 +174,9 @@ class OrderProcessingService
$initialStatus,
$currencySnapshot,
$guestLookupPassword,
$isZeroPayable
$isZeroPayable,
$cashReceiptInfo,
$refundBankInfo
) {
// 주문 생성
$order = $this->createOrder($tempOrder, $calculationResult, $initialStatus, $currencySnapshot, $guestLookupPassword, $shippingInfo, $paymentMethod);
@@ -181,7 +188,7 @@ class OrderProcessingService
$this->createOrderAddresses($order, $ordererInfo, $shippingInfo, $shippingMemo);
// 결제 정보 생성
$this->createOrderPayment($order, $paymentMethod, $depositorName, $dbankInfo, $calculationResult, $currencySnapshot, $ordererInfo);
$this->createOrderPayment($order, $paymentMethod, $depositorName, $dbankInfo, $calculationResult, $currencySnapshot, $ordererInfo, $cashReceiptInfo, $refundBankInfo);
// 배송 정보 생성 (주문 옵션과 연결)
$this->createOrderShippings($order, $tempOrder, $calculationResult, $currencySnapshot, $createdOptions);
@@ -925,6 +932,8 @@ class OrderProcessingService
* @param OrderCalculationResult $calculationResult 계산 결과
* @param array $currencySnapshot 통화 스냅샷
* @param array $ordererInfo 주문자 정보 (name/email/phone — 결제 구매자 정보로 기록)
* @param array|null $cashReceiptInfo 현금영수증 신청 정보 (type/identifier_type/identifier, 미신청 시 null)
* @param array|null $refundBankInfo 환불 계좌 정보 (bank_code/account_number/holder, 미입력 시 null)
*/
protected function createOrderPayment(
Order $order,
@@ -933,7 +942,9 @@ class OrderProcessingService
?array $dbankInfo,
OrderCalculationResult $calculationResult,
array $currencySnapshot,
array $ordererInfo = []
array $ordererInfo = [],
?array $cashReceiptInfo = null,
?array $refundBankInfo = null
): void {
$paymentAmount = $calculationResult->summary->paymentAmount ?? $calculationResult->summary->finalAmount ?? 0;
// 결제액 0원(전액 비현금 충당: 마일리지/예치금 등) → 결제 레코드도 즉시 PAID, PG/현금 결제액 0.
@@ -994,10 +1005,7 @@ class OrderProcessingService
// bank_name이 없으면 설정에서 은행코드 기반으로 조회
$bankName = $dbankInfo['bank_name'] ?? null;
if (! $bankName && $bankCode) {
$orderSettings = module_setting('sirsoft-ecommerce', 'order_settings');
$banks = collect($orderSettings['banks'] ?? []);
$bank = $banks->firstWhere('code', $bankCode);
$bankName = $bank ? ($bank['name'][app()->getLocale()] ?? $bank['name']['ko'] ?? $bankCode) : $bankCode;
$bankName = $this->resolveBankName($bankCode);
}
$paymentData['dbank_code'] = $bankCode;
@@ -1011,6 +1019,28 @@ class OrderProcessingService
$paymentData['deposit_due_at'] = Carbon::now()->addDays($this->resolveAutoCancelDays());
}
// 현금영수증 신청 정보 — 발급은 입금완료 시점에 리스너가 수행하고, 여기서는 신청 내역만 보관한다.
// 원본 식별번호는 암호화 컬럼에만 두고 평문 컬럼에는 마스킹본을 저장한다(응답·로그 노출 방지).
// 구매확정 시점에 PurgeCashReceiptIdentifierListener 가 암호문을 폐기한다.
if ($cashReceiptInfo !== null) {
$identifier = $cashReceiptInfo['identifier'];
$paymentData['is_cash_receipt_requested'] = true;
$paymentData['cash_receipt_type'] = $cashReceiptInfo['type']->value;
$paymentData['cash_receipt_identifier_type'] = $cashReceiptInfo['identifier_type'];
$paymentData['cash_receipt_identifier'] = CashReceiptIdentifierType::mask($identifier);
$paymentData['cash_receipt_identifier_encrypted'] = $identifier;
}
// 환불 계좌 — 무통장은 관리자 수동 이체 대상 계좌, 가상계좌는 PG 환불 API 의 refundReceiveAccount.
// 미입력 시 관리자 취소 모달에서 다시 받는다.
if ($refundBankInfo !== null) {
$paymentData['refund_bank_code'] = $refundBankInfo['bank_code'];
$paymentData['refund_bank_name'] = $this->resolveBankName($refundBankInfo['bank_code']);
$paymentData['refund_bank_account'] = $refundBankInfo['account_number'];
$paymentData['refund_bank_holder'] = $refundBankInfo['holder'];
}
$order->payment()->create($paymentData);
}
@@ -1050,6 +1080,20 @@ class OrderProcessingService
return max($min, min($max, $days));
}
/**
* 은행코드로 은행명을 조회합니다.
*
* 조회 규칙의 SSoT 는 EcommerceSettingsService 다 — 무통장 입금 계좌와 환불 계좌가
* 같은 은행 목록을 공유하므로 규칙을 복제하지 않고 위임한다.
*
* @param string $bankCode 은행코드
* @return string 은행명 (미등록 코드는 코드 그대로)
*/
protected function resolveBankName(string $bankCode): string
{
return $this->settingsService->resolveBankName($bankCode);
}
/**
* 결제명(상품명 요약)을 생성합니다.
*
@@ -35,6 +35,16 @@ return [
'failed' => 'Failed',
],
/*
* Ledger result column — issue and cancel rows share this column,
* so the wording must be neutral with respect to the transaction type.
*/
'result_status' => [
'in_progress' => 'In Progress',
'completed' => 'Completed',
'failed' => 'Failed',
],
/*
* 배송비 과세 정책
*/
@@ -912,6 +912,13 @@ return [
'guest_lookup_password_min' => 'The order lookup password must be at least 8 characters.',
'guest_lookup_password_confirmed' => 'The order lookup password confirmation does not match.',
'guest_lookup_password_confirmation_required' => 'Please confirm the order lookup password.',
'cash_receipt_type_required' => 'Please select the cash receipt purpose.',
'cash_receipt_type_invalid' => 'The selected cash receipt purpose is invalid.',
'cash_receipt_identifier_type_required' => 'Please select the cash receipt identifier type.',
'cash_receipt_identifier_type_invalid' => 'The selected cash receipt identifier type is invalid.',
'cash_receipt_identifier_required' => 'Please enter the number to use for the cash receipt.',
'refund_bank_required_with' => 'The refund account requires the bank, account number, and account holder.',
'refund_bank_required_for_vbank' => 'A refund account is required for a paid virtual account order.',
],
// Guest order lookup verification validation messages
@@ -35,6 +35,16 @@ return [
'failed' => '발급 실패',
],
/*
* 이력 표의 결과 열 — 발급 행과 취소 행이 같은 열을 공유하므로 거래유형과 무관한 중립 표현을 쓴다.
* (취소 행에 "발급 완료" 라고 쓰면 관리자가 오해한다)
*/
'result_status' => [
'in_progress' => '처리 중',
'completed' => '완료',
'failed' => '실패',
],
/*
* 배송비 과세 정책
*/
@@ -912,6 +912,13 @@ return [
'guest_lookup_password_min' => '주문 조회 비밀번호는 8자 이상이어야 합니다.',
'guest_lookup_password_confirmed' => '주문 조회 비밀번호가 일치하지 않습니다.',
'guest_lookup_password_confirmation_required' => '주문 조회 비밀번호 확인을 입력해주세요.',
'cash_receipt_type_required' => '현금영수증 발급 용도를 선택해주세요.',
'cash_receipt_type_invalid' => '현금영수증 발급 용도가 올바르지 않습니다.',
'cash_receipt_identifier_type_required' => '현금영수증 발급수단을 선택해주세요.',
'cash_receipt_identifier_type_invalid' => '현금영수증 발급수단이 올바르지 않습니다.',
'cash_receipt_identifier_required' => '현금영수증 발급에 사용할 번호를 입력해주세요.',
'refund_bank_required_with' => '환불 계좌는 은행·계좌번호·예금주를 모두 입력해야 합니다.',
'refund_bank_required_for_vbank' => '입금이 완료된 가상계좌 주문은 환불 계좌가 필요합니다.',
],
// 비회원 주문 조회 인증 검증 메시지
@@ -0,0 +1,273 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
use App\Models\User;
use Illuminate\Support\Facades\Config;
use Illuminate\Testing\TestResponse;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\SequenceType;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Models\Sequence;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 관리자 주문 취소 시 환불계좌 조건부 검증 (#454 S3 / D13)
*
* 표시·필수 규칙:
* vbank + 입금완료 → 필수 (PG 환불 API 의 refundReceiveAccount)
* vbank + 입금전 → 선택 (미입금 취소는 PG 가 계좌를 요구하지 않음)
* dbank → 선택 (관리자 수동 이체 참조)
* card → 불필요
*
* 부분 입력(3필드 중 일부)은 결제수단과 무관하게 거부한다 — 그 상태로는 환불이 불가능하다.
*
* @scenario actor=admin, change_mode=manual
*
* @effects cancel_refund_bank_required_for_paid_vbank_422,
* cancel_refund_bank_optional_before_deposit,
* cancel_refund_bank_partial_input_rejected_422,
* cancel_refund_bank_persisted_to_payment_row
*/
class CancelOrderRefundBankTest extends ModuleTestCase
{
protected User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser([
'sirsoft-ecommerce.orders.read',
'sirsoft-ecommerce.orders.update',
]);
$settingsDir = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (is_dir($settingsDir)) {
foreach (glob($settingsDir.'/*.json') as $file) {
@unlink($file);
}
}
app(EcommerceSettingsService::class)->clearCache();
Config::set('g7_settings.modules.sirsoft-ecommerce', []);
$this->createCancelSequences();
}
private function createCancelSequences(): void
{
foreach ([SequenceType::CANCEL, SequenceType::REFUND] as $type) {
$config = $type->getDefaultConfig();
Sequence::firstOrCreate(
['type' => $type->value],
[
'algorithm' => $config['algorithm']->value,
'prefix' => $config['prefix'],
'current_value' => 0,
'increment' => 1,
'min_value' => 1,
'max_value' => $config['max_value'],
'cycle' => false,
'pad_length' => $config['pad_length'],
]
);
}
}
/**
* 결제완료 주문 + 결제행을 만든다.
*
* @param array<string, mixed> $paymentOverrides
* @return array{order: Order, payment: OrderPayment}
*/
private function createPaidOrder(array $paymentOverrides = []): array
{
$user = User::factory()->create();
$unitPrice = 20000;
$order = Order::factory()->create([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'subtotal_amount' => $unitPrice,
'total_amount' => $unitPrice,
'total_paid_amount' => $unitPrice,
'total_due_amount' => 0,
'total_cancelled_amount' => 0,
'cancellation_count' => 0,
'paid_at' => now(),
'promotions_applied_snapshot' => [],
'shipping_policy_applied_snapshot' => [],
]);
$snapshotOverride = [
'product_snapshot' => [
'id' => null, 'name' => ['ko' => 't', 'en' => 't'], 'product_code' => null,
'sku' => null, 'brand_id' => null, 'list_price' => $unitPrice, 'selling_price' => $unitPrice,
'currency_code' => 'KRW', 'stock_quantity' => 100, 'tax_status' => 'taxable',
'tax_rate' => 10, 'has_options' => false, 'option_groups' => null, 'thumbnail_url' => null,
],
'option_snapshot' => [
'id' => null, 'option_code' => null, 'option_values' => null, 'option_name' => 't',
'price_adjustment' => 0, 'list_price' => $unitPrice, 'selling_price' => $unitPrice,
'currency_code' => 'KRW', 'stock_quantity' => 100, 'weight' => 0, 'volume' => 0,
],
];
OrderOption::factory()->forOrder($order)->create(array_merge([
'quantity' => 1,
'unit_price' => $unitPrice,
'subtotal_price' => $unitPrice,
'subtotal_paid_amount' => $unitPrice,
'subtotal_discount_amount' => 0,
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
], $snapshotOverride));
$payment = OrderPayment::factory()->forOrder($order)->create(array_merge([
'payment_method' => PaymentMethodEnum::VBANK,
'payment_status' => PaymentStatusEnum::PAID,
'paid_amount_local' => $unitPrice,
'paid_amount_base' => $unitPrice,
'paid_at' => now(),
'refund_bank_code' => null,
'refund_bank_name' => null,
'refund_bank_account' => null,
'refund_bank_holder' => null,
], $paymentOverrides));
return compact('order', 'payment');
}
/**
* @param array<string, mixed> $body
*/
private function cancel(Order $order, array $body = []): TestResponse
{
return $this->actingAs($this->adminUser)->postJson(
"/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel",
array_merge(['type' => 'full', 'reason' => 'changed_mind', 'cancel_pg' => false], $body)
);
}
public function test_주문상세_응답이_환불계좌를_노출한다(): void
{
// 취소 모달은 주문 시 입력된 계좌를 프리필한다. 응답에 키가 없으면 프리필이 불가능하다.
['order' => $order] = $this->createPaidOrder([
'refund_bank_code' => '088',
'refund_bank_name' => '신한은행',
'refund_bank_account' => '110-123-456789',
'refund_bank_holder' => '홍길동',
]);
$this->actingAs($this->adminUser)
->getJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}")
->assertOk()
->assertJsonPath('data.payment.refund_bank_code', '088')
->assertJsonPath('data.payment.refund_bank_name', '신한은행')
->assertJsonPath('data.payment.refund_bank_account', '110-123-456789')
->assertJsonPath('data.payment.refund_bank_holder', '홍길동');
}
// ------------------------------------------------- vbank 입금완료 = 필수
public function test_가상계좌_입금완료_취소는_환불계좌가_없으면_거부된다(): void
{
['order' => $order] = $this->createPaidOrder();
$this->cancel($order)
->assertStatus(422)
->assertJsonValidationErrors([
'refund_bank.bank_code',
'refund_bank.account_number',
'refund_bank.holder',
]);
$order->refresh();
$this->assertNotSame(OrderStatusEnum::CANCELLED, $order->order_status, '검증 실패 시 취소되면 안 된다');
}
public function test_주문시_입력된_환불계좌가_있으면_취소_요청에_다시_넣지_않아도_된다(): void
{
['order' => $order] = $this->createPaidOrder([
'refund_bank_code' => '004',
'refund_bank_name' => '국민은행',
'refund_bank_account' => '110-123-456789',
'refund_bank_holder' => '홍길동',
]);
$this->cancel($order)->assertOk();
}
public function test_가상계좌_입금완료_취소에_환불계좌를_주면_통과하고_결제행에_저장된다(): void
{
['order' => $order, 'payment' => $payment] = $this->createPaidOrder();
$this->cancel($order, [
'refund_bank' => [
'bank_code' => '004',
'account_number' => '110-123-456789',
'holder' => '홍길동',
],
])->assertOk();
$payment->refresh();
$this->assertSame('004', $payment->refund_bank_code);
$this->assertSame('110-123-456789', $payment->refund_bank_account);
$this->assertSame('홍길동', $payment->refund_bank_holder);
$this->assertNotNull($payment->refund_bank_name, '은행명이 은행코드로 조회되어 저장되어야 한다');
}
// ------------------------------------------------- vbank 입금전 / dbank = 선택
public function test_가상계좌_입금전_취소는_환불계좌가_없어도_된다(): void
{
['order' => $order] = $this->createPaidOrder([
'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT,
]);
$this->cancel($order)->assertOk();
}
public function test_무통장_취소는_환불계좌가_없어도_된다(): void
{
['order' => $order] = $this->createPaidOrder([
'payment_method' => PaymentMethodEnum::DBANK,
]);
$this->cancel($order)->assertOk();
}
public function test_카드_취소는_환불계좌가_없어도_된다(): void
{
['order' => $order] = $this->createPaidOrder([
'payment_method' => PaymentMethodEnum::CARD,
]);
$this->cancel($order)->assertOk();
}
// ------------------------------------------------- 부분 입력 거부 (결제수단 무관)
public function test_무통장이라도_환불계좌_부분_입력은_거부된다(): void
{
['order' => $order] = $this->createPaidOrder([
'payment_method' => PaymentMethodEnum::DBANK,
]);
$this->cancel($order, ['refund_bank' => ['account_number' => '110-123-456789']])
->assertStatus(422)
->assertJsonValidationErrors(['refund_bank.bank_code', 'refund_bank.holder']);
}
public function test_가상계좌_환불계좌_부분_입력은_거부된다(): void
{
['order' => $order] = $this->createPaidOrder();
$this->cancel($order, ['refund_bank' => ['bank_code' => '004', 'account_number' => '110-1']])
->assertStatus(422)
->assertJsonValidationErrors(['refund_bank.holder']);
}
}
@@ -0,0 +1,127 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Enums\ShippingFeeTaxPolicy;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 현금영수증 환경설정 저장 (W-5 / #454 S3)
*
* 관리자 환경설정 UI 가 저장하는 3키가 실제로 DB 에 반영되는지 검증한다.
* FormRequest 에 rules 를 명시하지 않으면 validated() 가 키를 떨궈 저장이 조용히 무효화된다 —
* 이 테스트가 그 회귀를 고정한다.
*
* @scenario actor=admin, change_mode=manual
*
* @effects settings_cash_receipt_provider_persisted,
* settings_shipping_fee_tax_policy_persisted,
* settings_cash_receipt_self_issue_persisted,
* settings_invalid_shipping_fee_tax_policy_rejected_422
*/
class EcommerceSettingsCashReceiptTest extends ModuleTestCase
{
private string $apiBase = '/api/modules/sirsoft-ecommerce/admin/settings';
private User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser([
'sirsoft-ecommerce.settings.read',
'sirsoft-ecommerce.settings.update',
]);
}
private function settings(): EcommerceSettingsService
{
$settings = app(EcommerceSettingsService::class);
$settings->clearCache();
return $settings;
}
public function test_현금영수증_프로바이더가_저장된다(): void
{
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'order_settings',
'order_settings' => ['cash_receipt_provider' => 'tosspayments'],
])->assertOk();
$this->assertSame('tosspayments', $this->settings()->getSetting('order_settings.cash_receipt_provider'));
$this->assertSame('tosspayments', $this->settings()->getCashReceiptProvider());
}
public function test_프로바이더를_빈_문자열로_저장하면_미사용이_된다(): void
{
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'order_settings',
'order_settings' => ['cash_receipt_provider' => ''],
])->assertOk();
// 빈 문자열은 "미사용" 이며 접근자는 null 로 정규화한다.
$this->assertNull($this->settings()->getCashReceiptProvider());
}
public function test_배송비_과세_정책이_저장된다(): void
{
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'order_settings',
'order_settings' => ['shipping_fee_tax_policy' => ShippingFeeTaxPolicy::TAXABLE->value],
])->assertOk();
$this->assertSame(
ShippingFeeTaxPolicy::TAXABLE,
$this->settings()->getShippingFeeTaxPolicy()
);
}
public function test_배송비_과세_정책_3종이_모두_저장된다(): void
{
foreach (ShippingFeeTaxPolicy::cases() as $policy) {
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'order_settings',
'order_settings' => ['shipping_fee_tax_policy' => $policy->value],
])->assertOk();
$this->assertSame($policy, $this->settings()->getShippingFeeTaxPolicy(), $policy->value);
}
}
public function test_알_수_없는_배송비_과세_정책은_거부된다(): void
{
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'order_settings',
'order_settings' => ['shipping_fee_tax_policy' => 'half_taxable'],
])->assertStatus(422)
->assertJsonValidationErrors(['order_settings.shipping_fee_tax_policy']);
}
public function test_자진발급_토글이_저장된다(): void
{
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'order_settings',
'order_settings' => ['cash_receipt_self_issue' => true],
])->assertOk();
$this->assertTrue($this->settings()->isCashReceiptSelfIssueEnabled());
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'order_settings',
'order_settings' => ['cash_receipt_self_issue' => false],
])->assertOk();
$this->assertFalse($this->settings()->isCashReceiptSelfIssueEnabled());
}
public function test_설정_조회_응답에_발급_프로바이더_후보_목록이_포함된다(): void
{
// 등록된 프로바이더가 없어도 키 자체는 존재해야 한다 (관리자 UI 가 length 로 분기한다).
$this->actingAs($this->adminUser)->getJson($this->apiBase)
->assertOk()
->assertJsonStructure(['data' => ['available_cash_receipt_providers']]);
}
}
@@ -0,0 +1,407 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Public;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
use Modules\Sirsoft\Ecommerce\Enums\ProductSalesStatus;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
/**
* 주문 생성 시 현금영수증 신청 + 환불계좌 수집 검증 (#454 S3)
*
* 검증 축:
* - FormRequest 4단계 사슬: rules 명시 → getter → createFromTempOrder 시그니처 → createOrderPayment 저장
* (한 단계라도 빠지면 프론트가 값을 보내도 DB 에 남지 않는다)
* - 식별번호는 평문 컬럼에 마스킹본, 암호화 컬럼에 원본
* - 용도 × 식별번호 조합 (D10 — 지출증빙+휴대폰 허용 / 지출증빙+자진발급번호 거부)
* - 환불계좌 부분 입력 거부 (required_with 상당)
*
* @scenario actor=guest, change_mode=manual
*
* @effects checkout_cash_receipt_request_persisted, checkout_refund_bank_persisted,
* checkout_cash_receipt_identifier_masked_and_encrypted,
* checkout_refund_bank_partial_input_rejected_422,
* cash_receipt_request_not_persisted_for_non_dbank
*/
class CheckoutCashReceiptAndRefundBankTest extends ModuleTestCase
{
protected string $cartKey;
protected Product $product;
protected ProductOption $productOption;
protected function setUp(): void
{
parent::setUp();
$this->cartKey = Str::uuid()->toString();
$this->product = Product::create([
'name' => ['ko' => '테스트 상품', 'en' => 'Test Product'],
'product_code' => 'TEST-'.Str::random(8),
'sku' => 'SKU-'.Str::random(8),
'list_price' => 20000,
'selling_price' => 15000,
'currency_code' => 'KRW',
'stock_quantity' => 100,
'sales_status' => ProductSalesStatus::ON_SALE,
'display_status' => ProductDisplayStatus::VISIBLE,
'has_options' => true,
]);
$this->productOption = ProductOption::create([
'product_id' => $this->product->id,
'option_code' => 'OPT-'.Str::random(8),
'option_values' => ['색상' => '검정'],
'option_name' => null,
'sku' => 'SKU-'.Str::random(8),
'price_adjustment' => 0,
'stock_quantity' => 50,
'safe_stock_quantity' => 5,
'is_default' => true,
'is_active' => true,
'sort_order' => 1,
]);
}
protected function createTempOrder(): TempOrder
{
return TempOrder::create([
'user_id' => null,
'cart_key' => $this->cartKey,
'items' => [[
'product_id' => $this->product->id,
'product_option_id' => $this->productOption->id,
'quantity' => 2,
]],
'calculation_result' => [
'items' => [[
'product_id' => $this->product->id,
'product_option_id' => $this->productOption->id,
'quantity' => 2,
'unit_price' => 15000,
'subtotal' => 30000,
'final_amount' => 30000,
]],
'summary' => [
'subtotal' => 30000,
'total_discount' => 0,
'total_shipping' => 0,
'payment_amount' => 30000,
'final_amount' => 30000,
],
],
'expires_at' => now()->addMinutes(30),
]);
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
protected function payload(array $overrides = []): array
{
return array_merge([
'orderer' => ['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'guest@test.com'],
'shipping' => [
'recipient_name' => '김철수',
'recipient_phone' => '010-9876-5432',
'country_code' => 'KR',
'zipcode' => '12345',
'address' => '서울시 강남구 테헤란로 123',
'address_detail' => '101동 1001호',
],
'payment_method' => PaymentMethodEnum::DBANK->value,
'expected_total_amount' => 30000,
'depositor_name' => '홍길동',
'dbank' => [
'bank_code' => '004',
'bank_name' => '국민은행',
'account_number' => '123-456-789012',
'account_holder' => '주식회사 테스트',
],
'guest_lookup_password' => 'guest1234',
'guest_lookup_password_confirmation' => 'guest1234',
], $overrides);
}
/**
* @param array<string, mixed> $overrides
*/
protected function postOrder(array $overrides = []): TestResponse
{
$this->createTempOrder();
return $this->postJson(
'/api/modules/sirsoft-ecommerce/user/orders',
$this->payload($overrides),
['X-Cart-Key' => $this->cartKey]
);
}
protected function latestPayment(): OrderPayment
{
$order = Order::latest('id')->firstOrFail();
return $order->payment()->firstOrFail();
}
// ---------------------------------------------------------------- 현금영수증
public function test_현금영수증_미신청_주문은_신청_플래그가_꺼진다(): void
{
$this->postOrder()->assertStatus(201);
$payment = $this->latestPayment();
$this->assertFalse((bool) $payment->is_cash_receipt_requested);
$this->assertNull($payment->cash_receipt_type);
$this->assertNull($payment->cash_receipt_identifier);
$this->assertNull($payment->getRawOriginal('cash_receipt_identifier_encrypted'));
}
public function test_현금영수증_신청이_결제행에_저장된다(): void
{
$this->postOrder([
'cash_receipt_requested' => true,
'cash_receipt_type' => CashReceiptType::INCOME->value,
'cash_receipt_identifier_type' => CashReceiptIdentifierType::PHONE->value,
'cash_receipt_identifier' => '010-1234-5678',
])->assertStatus(201);
$payment = $this->latestPayment();
$this->assertTrue((bool) $payment->is_cash_receipt_requested);
$this->assertSame(CashReceiptType::INCOME->value, $payment->cash_receipt_type);
$this->assertSame(CashReceiptIdentifierType::PHONE, $payment->cash_receipt_identifier_type);
}
public function test_식별번호는_평문_컬럼에_마스킹본_암호화_컬럼에_원본이_저장된다(): void
{
$this->postOrder([
'cash_receipt_requested' => true,
'cash_receipt_type' => CashReceiptType::INCOME->value,
'cash_receipt_identifier_type' => CashReceiptIdentifierType::PHONE->value,
'cash_receipt_identifier' => '010-1234-5678',
])->assertStatus(201);
$payment = $this->latestPayment();
// 하이픈 제거 후 뒤 4자리만 노출
$this->assertSame('*******5678', $payment->cash_receipt_identifier);
// 원본은 복호화로만 얻는다 (raw 컬럼에 평문이 없어야 한다)
$this->assertSame('01012345678', $payment->cash_receipt_identifier_encrypted);
$this->assertStringNotContainsString(
'01012345678',
(string) $payment->getRawOriginal('cash_receipt_identifier_encrypted')
);
}
public function test_지출증빙에_휴대폰번호를_쓸_수_있다(): void
{
// D10 — 휴대폰번호는 소득공제/지출증빙 양쪽에서 유효하다
$this->postOrder([
'cash_receipt_requested' => true,
'cash_receipt_type' => CashReceiptType::EXPENSE->value,
'cash_receipt_identifier_type' => CashReceiptIdentifierType::PHONE->value,
'cash_receipt_identifier' => '010-1234-5678',
])->assertStatus(201);
$this->assertSame(CashReceiptType::EXPENSE->value, $this->latestPayment()->cash_receipt_type);
}
public function test_지출증빙에_자진발급번호는_거부된다(): void
{
// 자진발급 지정번호는 소득공제 전용 (제도상 지출증빙 자진발급 불가)
$this->postOrder([
'cash_receipt_requested' => true,
'cash_receipt_type' => CashReceiptType::EXPENSE->value,
'cash_receipt_identifier_type' => CashReceiptIdentifierType::PHONE->value,
'cash_receipt_identifier' => CashReceiptIdentifierType::SELF_ISSUE_NUMBER,
])->assertStatus(422)
->assertJsonPath('errors.cash_receipt_identifier.0', __('sirsoft-ecommerce::cash_receipt.validation.self_issue_income_only'));
}
public function test_사업자등록번호_체크섬이_틀리면_거부된다(): void
{
$this->postOrder([
'cash_receipt_requested' => true,
'cash_receipt_type' => CashReceiptType::EXPENSE->value,
'cash_receipt_identifier_type' => CashReceiptIdentifierType::BUSINESS->value,
'cash_receipt_identifier' => '1234567890',
])->assertStatus(422);
$this->assertSame(0, Order::count(), '검증 실패 시 주문 부산물이 남아서는 안 된다');
}
public function test_신청했으나_하위키가_없으면_거부된다(): void
{
$this->postOrder(['cash_receipt_requested' => true])
->assertStatus(422)
->assertJsonValidationErrors([
'cash_receipt_type',
'cash_receipt_identifier_type',
'cash_receipt_identifier',
]);
}
/**
* 무통장이 아닌 결제수단으로 신청정보가 넘어와도 결제행에 저장하지 않는다.
*
* 발급 자체는 CashReceiptService 가 dbank 로 차단하므로 기능 홀은 아니지만,
* 발급될 수 없는 주문에 식별번호 암호문을 남기는 것은 불필요한 개인정보 보관이다.
* getDbankInfo() 와 동일하게 getCashReceiptInfo() 도 결제수단으로 게이팅한다.
*
* @param string $paymentMethod 무통장이 아닌 결제수단
*/
#[DataProvider('nonDbankPaymentMethodProvider')]
public function test_무통장이_아니면_현금영수증_신청정보를_저장하지_않는다(string $paymentMethod): void
{
$this->postOrder([
'payment_method' => $paymentMethod,
// vbank 는 입금자명이 필수다(required_if). dbank 전용 계좌정보만 비운다.
'depositor_name' => $paymentMethod === PaymentMethodEnum::VBANK->value ? '홍길동' : null,
'dbank' => null,
'cash_receipt_requested' => true,
'cash_receipt_type' => CashReceiptType::INCOME->value,
'cash_receipt_identifier_type' => CashReceiptIdentifierType::PHONE->value,
'cash_receipt_identifier' => '010-1234-5678',
])->assertStatus(201);
$payment = $this->latestPayment();
$this->assertFalse(
(bool) $payment->is_cash_receipt_requested,
"{$paymentMethod} 주문에 현금영수증 신청 플래그가 켜져서는 안 된다"
);
$this->assertNull($payment->cash_receipt_type);
$this->assertNull($payment->cash_receipt_identifier_type);
$this->assertNull($payment->cash_receipt_identifier, '마스킹본도 남기지 않는다');
$this->assertNull(
$payment->getRawOriginal('cash_receipt_identifier_encrypted'),
'발급될 수 없는 주문에 식별번호 암호문을 보관해서는 안 된다'
);
}
/**
* 무통장(dbank)을 제외한 전 결제수단.
*
* @return array<string, array{string}>
*/
public static function nonDbankPaymentMethodProvider(): array
{
$cases = [];
foreach (PaymentMethodEnum::cases() as $method) {
if ($method === PaymentMethodEnum::DBANK) {
continue;
}
$cases[$method->value] = [$method->value];
}
return $cases;
}
// ---------------------------------------------------------------- 환불계좌
public function test_환불계좌_미입력이_허용된다(): void
{
$this->postOrder()->assertStatus(201);
$payment = $this->latestPayment();
$this->assertNull($payment->refund_bank_code);
$this->assertNull($payment->refund_bank_account);
$this->assertNull($payment->refund_bank_holder);
}
public function test_환불계좌_전체_입력이_결제행에_저장된다(): void
{
$this->postOrder([
'refund_bank' => [
'bank_code' => '004',
'account_number' => '110-123-456789',
'holder' => '홍길동',
],
])->assertStatus(201);
$payment = $this->latestPayment();
$this->assertSame('004', $payment->refund_bank_code);
$this->assertSame('110-123-456789', $payment->refund_bank_account);
$this->assertSame('홍길동', $payment->refund_bank_holder);
// 은행명은 은행코드로 조회해 함께 저장한다 (관리자 화면 표시용)
$this->assertNotNull($payment->refund_bank_name);
}
/**
* @return array<string, array{array<string, mixed>, array<int, string>}>
*/
public static function partialRefundBankProvider(): array
{
return [
'계좌번호만' => [
['account_number' => '110-123-456789'],
['refund_bank.bank_code', 'refund_bank.holder'],
],
'은행만' => [
['bank_code' => '004'],
['refund_bank.account_number', 'refund_bank.holder'],
],
'예금주 누락' => [
['bank_code' => '004', 'account_number' => '110-123-456789'],
['refund_bank.holder'],
],
];
}
/**
* @param array<string, mixed> $refundBank
* @param array<int, string> $expectedErrors
*/
#[DataProvider('partialRefundBankProvider')]
public function test_환불계좌_부분_입력은_거부된다(array $refundBank, array $expectedErrors): void
{
$this->postOrder(['refund_bank' => $refundBank])
->assertStatus(422)
->assertJsonValidationErrors($expectedErrors);
$this->assertSame(0, Order::count(), '검증 실패 시 주문 부산물이 남아서는 안 된다');
}
// ---------------------------------------------------------------- 사슬 무결성
public function test_현금영수증과_환불계좌를_함께_보내면_둘_다_저장된다(): void
{
$this->postOrder([
'cash_receipt_requested' => true,
'cash_receipt_type' => CashReceiptType::INCOME->value,
'cash_receipt_identifier_type' => CashReceiptIdentifierType::PHONE->value,
'cash_receipt_identifier' => '010-1234-5678',
'refund_bank' => [
'bank_code' => '004',
'account_number' => '110-123-456789',
'holder' => '홍길동',
],
])->assertStatus(201);
$payment = $this->latestPayment();
$this->assertTrue((bool) $payment->is_cash_receipt_requested);
$this->assertSame('004', $payment->refund_bank_code);
}
}
@@ -0,0 +1,315 @@
/**
* 체크아웃 현금영수증 신청 폼 E2E (#454 S3 / W-1).
*
* 이 spec 이 검증하는 것은 "슬롯에 주입된 모듈 폼이 브라우저에서 실제로 동작하는가" 다.
* 레이아웃 JSON 단위 테스트(cashReceiptUi.test.tsx)는 JSON 구조만 읽으므로,
* 확장 주입 → 렌더 → 상호작용 → 상태 반영의 실경로는 브라우저에서만 확인된다.
*
* 실측으로만 드러난 결함이 실제로 있었다(계획서 §12-3): 무통장 결제 상태가 waiting_deposit 이
* 아니라 ready 였고, 유닛 픽스처는 내가 넣어준 값이라 green 이었다. 그래서 본 spec 은
* 픽스처 대신 라이브 API/DOM 만 신뢰한다.
*
* 시드는 도메인 seeder(playwright:seed-ecommerce, 아직 stub)를 쓰지 않는다.
* 대신 사용자가 실제로 밟는 경로 — 인증 토큰 발급 → 카트 담기 API → 체크아웃 진입 — 을 그대로 밟는다.
* 검증 대상(현금영수증 폼)을 우회하지 않으므로 "강제 API 로 통과 선언" 에 해당하지 않는다.
*
* @scenario cash-receipt-ui-and-refund-bank
* @effects checkout_slot_renders_only_inside_dbank_block,
* checkout_slot_hidden_when_provider_unset,
* checkout_fields_mount_on_request_toggle,
* checkout_purpose_switch_resets_invalid_identifier,
* checkout_identifier_type_options_by_purpose,
* checkout_identifier_cleared_on_type_change,
* new_ui_uses_portable_only,
* new_ui_has_no_tailwind_breakpoint
*/
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
import type { Page } from '@playwright/test';
/**
* 카트 담기에 쓰는 상품 — 전 상품이 옵션 보유(has_options=true)라 option_values 가 필수다.
*
* CartService::bulkAddToCart 는 저장된 다국어 JSON 이 아니라 현재 로케일로 평탄화된 맵
* (`{"색상":"화이트"}`) 과 비교한다(getLocalizedOptionValues). 그래서 요청 로케일을 ko 로 고정한다.
*/
const CART_LOCALE = 'ko';
const CART_ITEM = {
product_id: 1,
items: [{ option_values: { 색상: '화이트' }, quantity: 1 }],
};
/** 발급수단 드롭다운의 항목 라벨 (ko 고정 — beforeEach 가 g7_locale 을 ko 로 pin 한다) */
const IDENTIFIER_LABEL = {
phone: '휴대폰번호',
card: '현금영수증 카드번호',
business: '사업자등록번호',
} as const;
const SLOT = '#ext_checkout_cash_receipt';
const FIELDS = '#ext_checkout_cash_receipt_fields';
const TOGGLE = '#ext_checkout_cash_receipt_toggle';
const PURPOSE = '#ext_checkout_cash_receipt_purpose';
const ID_TYPE = '#ext_checkout_cash_receipt_identifier_type';
const IDENTIFIER = '#ext_checkout_cash_receipt_identifier';
/**
* 카트를 비우고 상품 1건을 담은 뒤 임시주문을 생성한다.
*
* 체크아웃 화면은 카트에 상품이 있는 것만으로는 열리지 않는다 — 카트의 "주문하기" 가
* POST /checkout 으로 만든 임시주문이 있어야 하고, 없으면 "주문 정보를 찾을 수 없습니다"
* 모달이 뜨고 카트로 되돌아간다. 그래서 사용자가 밟는 두 단계를 그대로 밟는다.
*/
async function seedCheckout(page: Page): Promise<void> {
const result = await page.evaluate(
async ({ payload, locale }) => {
const token = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json',
'Accept-Language': locale,
Authorization: `Bearer ${token}`,
};
await fetch('/api/modules/sirsoft-ecommerce/cart/all', { method: 'DELETE', headers });
const added = await fetch('/api/modules/sirsoft-ecommerce/cart', {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
if (!added.ok) return { step: 'cart', status: added.status, body: await added.text() };
const cart = await fetch('/api/modules/sirsoft-ecommerce/cart', { headers }).then((r) => r.json());
const itemIds = (cart?.data?.items ?? cart?.data ?? []).map((i: { id: number }) => i.id);
if (itemIds.length === 0) {
return { step: 'cart-read', status: 0, body: JSON.stringify(cart).slice(0, 300) };
}
// 카트의 "주문하기" 버튼과 동일한 호출 (_cart_summary.json)
const checkout = await fetch('/api/modules/sirsoft-ecommerce/checkout', {
method: 'POST',
headers,
body: JSON.stringify({ item_ids: itemIds }),
});
return { step: 'checkout', status: checkout.status, body: await checkout.text() };
},
{ payload: CART_ITEM, locale: CART_LOCALE }
);
expect(result.status, `${result.step} 단계 실패: ${result.body}`).toBeLessThan(300);
}
/** 결제수단 버튼 — iteration 으로 렌더되며 method.id 로 식별된다. */
const paymentMethod = (page: Page, id: string) =>
page.getByTestId(`checkout-payment-method-${id}`);
/**
* GDPR 쿠키 동의 배너를 닫는다.
*
* 신규 브라우저 컨텍스트에는 동의 기록이 없어 전체 화면 오버레이가 클릭을 가로챈다.
* 동의 상태는 서버(/consent/cookie/status)가 SSoT 이므로 localStorage 를 조작하지 않고
* 실제 사용자와 같이 버튼을 누른다.
*/
async function dismissCookieNotice(page: Page): Promise<void> {
const necessaryOnly = page.getByRole('button', { name: /Necessary Only|필수만/i });
const overlay = page.locator('.fixed.inset-0');
// 배너는 /consent/cookie/status 응답 후 비동기로 마운트되고, 클릭 직후 자기 자신을 떼어낸다.
// 한 번만 확인하면 (a) 아직 없는 상태를 "없음" 으로 오판하거나 (b) 클릭 재시도가
// detach 와 겹쳐 실패한다. 오버레이가 사라질 때까지 클릭을 재시도한다.
for (let attempt = 0; attempt < 3; attempt += 1) {
if ((await overlay.count()) === 0) return;
if (!(await necessaryOnly.isVisible().catch(() => false))) {
await necessaryOnly.waitFor({ state: 'visible', timeout: 5_000 }).catch(() => undefined);
}
await necessaryOnly.click({ timeout: 5_000 }).catch(() => undefined);
const cleared = await expect
.poll(() => overlay.count(), { timeout: 5_000 })
.toBe(0)
.then(() => true, () => false);
if (cleared) return;
}
await expect(overlay, '쿠키 동의 오버레이가 닫히지 않았다').toHaveCount(0);
}
/** 체크아웃으로 이동하고 결제수단 블록이 그려질 때까지 기다린다. */
async function gotoCheckout(page: Page): Promise<void> {
await page.goto('/shop/checkout');
await page.waitForLoadState('domcontentloaded');
await dismissCookieNotice(page);
// 무통장 버튼이 뜨면 결제수단 섹션 렌더 완료
await paymentMethod(page, 'dbank').waitFor({ timeout: 30_000 });
}
/** 무통장입금을 선택한다 — 현금영수증 슬롯은 dbank 블록 내부에만 있다. */
async function selectDbank(page: Page): Promise<void> {
await paymentMethod(page, 'dbank').click();
await expect(page.locator(SLOT)).toBeVisible();
}
/**
* 발급수단 드롭다운을 열고 보이는 항목 라벨을 반환한다.
*
* 템플릿의 Select 는 네이티브 <select> 가 아니라 portal 로 띄우는 커스텀 드롭다운이다
* (role=listbox / role=option, value 속성 없음). 그래서 selectOption() 이 동작하지 않고
* 항목은 라벨 텍스트로만 식별된다.
*/
async function openIdentifierOptions(page: Page): Promise<string[]> {
await page.locator(ID_TYPE).click();
const listbox = page.getByRole('listbox');
await listbox.waitFor({ state: 'visible' });
return listbox.getByRole('option').allInnerTexts();
}
/** 발급수단 드롭다운에서 항목을 고른다. */
async function chooseIdentifierType(page: Page, key: keyof typeof IDENTIFIER_LABEL): Promise<void> {
await page.locator(ID_TYPE).click();
const listbox = page.getByRole('listbox');
await listbox.waitFor({ state: 'visible' });
await listbox.getByRole('option', { name: IDENTIFIER_LABEL[key], exact: true }).click();
await expect(listbox).toBeHidden();
}
test.describe('체크아웃 현금영수증 신청 폼 (무통장 슬롯 주입)', () => {
test.beforeEach(async ({ page, noPermissionToken }) => {
// 구매자 역할 — 관리자 권한 없이 상점 화면만 사용한다.
await authenticatePage(page, noPermissionToken);
// 드롭다운 항목은 라벨 텍스트로만 식별되므로 앱 로케일을 ko 로 고정한다
// (기본 브라우저 로케일이 en 이면 라벨이 달라져 spec 이 로케일에 종속된다).
await page.addInitScript((locale) => localStorage.setItem('g7_locale', locale), CART_LOCALE);
await page.goto('/shop');
await page.waitForLoadState('domcontentloaded');
await dismissCookieNotice(page);
await seedCheckout(page);
});
test('무통장 선택 시에만 현금영수증 슬롯이 렌더된다 (다른 결제수단에서는 0개)', async ({ page }) => {
await gotoCheckout(page);
// 무통장 → 슬롯 1개.
// 활성 결제수단이 무통장 하나뿐이면 진입 시점에 이미 선택되어 있다(슬롯도 이미 렌더).
// 그래서 "선택 전 0개" 를 단정하지 않는다 — 그건 결제수단 구성에 따라 달라지는 값이다.
await selectDbank(page);
await expect(page.locator(SLOT)).toHaveCount(1);
// 다른 결제수단으로 전환하면 슬롯이 사라진다.
// 활성 결제수단은 환경설정 가변이므로 dbank 아닌 활성 버튼이 있을 때만 전환을 검증한다.
// 전수(8종) 확인은 환경설정을 바꿔가며 수행하는 매트릭스 실측의 몫이다 — 여기서 활성화를
// 조작하면 병렬 워커가 공유하는 상점 설정을 오염시킨다.
const others = page.locator('[data-testid^="checkout-payment-method-"]:not([data-testid$="-dbank"])');
if ((await others.count()) === 0) {
test.info().annotations.push({
type: 'coverage-gap',
description: '무통장 외 활성 결제수단이 없어 전환 후 슬롯 소멸은 미검증 (환경설정 의존)',
});
return;
}
await others.first().click();
await expect(page.locator(SLOT)).toHaveCount(0);
});
test('신청 체크 전에는 입력 필드가 없고, 체크하면 마운트된다', async ({ page }) => {
await gotoCheckout(page);
await selectDbank(page);
await expect(page.locator(FIELDS)).toHaveCount(0);
await expect(page.locator('[name^="cash_receipt_"]')).toHaveCount(1); // 토글 체크박스만
await page.locator(TOGGLE).check();
await expect(page.locator(FIELDS)).toHaveCount(1);
await expect(page.locator(PURPOSE)).toBeVisible();
await expect(page.locator(ID_TYPE)).toBeVisible();
await expect(page.locator(IDENTIFIER)).toBeVisible();
// 해제하면 다시 사라진다
await page.locator(TOGGLE).uncheck();
await expect(page.locator(FIELDS)).toHaveCount(0);
});
test('용도에 따라 발급수단 선택지가 달라진다 — 지출증빙에만 사업자등록번호', async ({ page }) => {
await gotoCheckout(page);
await selectDbank(page);
await page.locator(TOGGLE).check();
// 기본값 = 소득공제 → 2종 (사업자등록번호 없음)
await expect(page.locator('input[name="cash_receipt_type"][value="income"]')).toBeChecked();
expect(await openIdentifierOptions(page)).toEqual([
IDENTIFIER_LABEL.phone,
IDENTIFIER_LABEL.card,
]);
await page.keyboard.press('Escape');
// 지출증빙 → 3종 (사업자등록번호 포함)
await page.locator('input[name="cash_receipt_type"][value="expense"]').check();
expect(await openIdentifierOptions(page)).toEqual([
IDENTIFIER_LABEL.business,
IDENTIFIER_LABEL.phone,
IDENTIFIER_LABEL.card,
]);
});
test('지출증빙+사업자등록번호에서 소득공제로 전환하면 발급수단과 번호가 리셋된다', async ({ page }) => {
await gotoCheckout(page);
await selectDbank(page);
await page.locator(TOGGLE).check();
// 지출증빙 → 사업자등록번호 선택 → 번호 입력
await page.locator('input[name="cash_receipt_type"][value="expense"]').check();
await chooseIdentifierType(page, 'business');
await page.locator(IDENTIFIER).fill('1234567890');
await expect(page.locator(IDENTIFIER)).toHaveValue('1234567890');
// 소득공제로 전환 — business 는 소득공제에 쓸 수 없으므로 phone 으로 리셋되고 번호는 비워진다
await page.locator('input[name="cash_receipt_type"][value="income"]').check();
await expect(page.locator(ID_TYPE)).toContainText(IDENTIFIER_LABEL.phone);
await expect(page.locator(IDENTIFIER)).toHaveValue('');
});
test('발급수단을 바꾸면 이전에 입력한 번호가 비워진다', async ({ page }) => {
await gotoCheckout(page);
await selectDbank(page);
await page.locator(TOGGLE).check();
await page.locator(IDENTIFIER).fill('01012345678');
await expect(page.locator(IDENTIFIER)).toHaveValue('01012345678');
await chooseIdentifierType(page, 'card');
await expect(page.locator(IDENTIFIER)).toHaveValue('');
});
test('반응형은 portable 단일 오버라이드로만 분기한다 (md:/lg: 브레이크포인트 없음)', async ({ page }) => {
await gotoCheckout(page);
await selectDbank(page);
await page.locator(TOGGLE).check();
// 신설 UI 안에 Tailwind 브레이크포인트 클래스가 없어야 한다 (§6-0)
const breakpointClasses = await page.locator(SLOT).evaluate((root) =>
Array.from(root.querySelectorAll('*'))
.flatMap((el) => Array.from(el.classList))
.filter((c) => /^(sm|md|lg|xl|2xl):/.test(c))
);
expect(breakpointClasses).toEqual([]);
const direction = () => page.locator(PURPOSE).evaluate((el) => getComputedStyle(el).flexDirection);
const columns = () => page.locator('#ext_checkout_cash_receipt_identifier_row')
.evaluate((el) => getComputedStyle(el).gridTemplateColumns.split(' ').length);
// 뷰포트 변경 후 responsive 오버라이드가 리렌더될 때까지 재시도한다 (즉시 읽으면 이전 값).
const expectLayout = async (width: number, dir: string, cols: number) => {
await page.setViewportSize({ width, height: 900 });
await expect.poll(direction, { message: `${width}px flexDirection` }).toBe(dir);
await expect.poll(columns, { message: `${width}px grid columns` }).toBe(cols);
};
// 데스크톱(1091px) — 가로 배치 / 2열
await expectLayout(1091, 'row', 2);
// 태블릿(900px)·모바일(390px) 은 동일 구간(portable 0~1023) — 세로 배치 / 1열
await expectLayout(900, 'column', 1);
await expectLayout(390, 'column', 1);
});
});
@@ -6,6 +6,7 @@ use App\Extension\HookManager;
use Illuminate\Http\Request;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIssueStatus;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptTransactionType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
@@ -213,6 +214,76 @@ class CashReceiptResourceTest extends ModuleTestCase
$this->assertSame('PROVIDER_ERROR', $ledger[0]['error_code']);
}
#[Test]
public function 리소스는_거래유형과_발급상태의_표시명을_함께_노출한다(): void
{
$this->registerProvider();
$order = $this->makeOrder();
app(CashReceiptService::class)->issue(
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
);
$receipt = $this->resourceArray($order)['cash_receipt'];
// 관리자 발급 이력 표는 원시값(issue / COMPLETED)이 아니라 표시명을 보여줘야 한다.
$this->assertSame(CashReceiptTransactionType::ISSUE->label(), $receipt['transaction_type_label']);
$this->assertSame(CashReceiptIssueStatus::COMPLETED->label(), $receipt['issue_status_label']);
}
#[Test]
public function 취소_이력의_상태는_발급_완료로_표시되지_않는다(): void
{
$this->registerProvider();
HookManager::addFilter(
'sirsoft-ecommerce.cash_receipt.cancel',
fn (array $result, Order $order, string $provider, string $receiptKey) => [
'success' => true, 'error_code' => null, 'error_message' => null,
'receipt_key' => $receiptKey, 'raw_response' => null,
],
);
$order = $this->makeOrder();
$service = app(CashReceiptService::class);
$service->issue($order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE);
$service->cancelAll($order->fresh(), '테스트 취소');
$ledger = $this->resourceArray($order)['cash_receipts'];
$cancelRow = collect($ledger)->firstWhere('transaction_type', CashReceiptTransactionType::CANCEL);
$this->assertNotNull($cancelRow, '취소 이력이 있어야 한다');
// issue_status 는 "그 거래가 성공했는가" 이므로 COMPLETED 다. 그러나 취소 행에 "발급 완료" 라고
// 쓰면 관리자가 오해한다 — 이력 표에는 거래유형과 무관한 중립 표현을 쓴다.
$this->assertSame(CashReceiptIssueStatus::COMPLETED->value, $cancelRow['issue_status']);
$this->assertStringNotContainsString('발급', $cancelRow['result_label']);
$this->assertNotEmpty($cancelRow['result_label']);
}
#[Test]
public function 발급에_실패한_이력도_표시용_일시를_가진다(): void
{
$this->registerProvider();
$this->issueShouldFail = true;
$order = $this->makeOrder();
try {
app(CashReceiptService::class)->issue(
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
);
} catch (\Throwable) {
// 발급 실패는 이력만 남기고 삼켜지거나 예외로 전파된다 — 어느 쪽이든 이력은 남는다.
}
$failed = $this->resourceArray($order)['cash_receipts'][0];
// 실패 이력은 issued_at 이 null 이다. 그대로 두면 관리자 이력 표의 일시가 '-' 로 비어 버린다.
$this->assertSame(CashReceiptIssueStatus::FAILED->value, $failed['issue_status']);
$this->assertNull($failed['issued_at']);
$this->assertNotEmpty($failed['occurred_at_formatted'], '실패 이력도 발생 시각을 표시할 수 있어야 한다');
}
#[Test]
public function 리소스는_프로바이더_원응답을_노출하지_않는다(): void
{
@@ -0,0 +1,246 @@
# audit:allow test-scenario-coverage reason: 14축 full cross product 691만 조합 — 전개 상한 초과.
# test_files 실재 검사는 이 면제와 무관하게 항상 수행된다(끄려면 `:test-files` 접미사).
# 실제 커버는 아래 effects 69건과 test_files 12개가 담당한다. S2 가 이월한 E2E axis 를 본 매니페스트에서 채운다.
feature: 현금영수증 신청/발급 UI · 환불계좌 수집
description: |
S1(인프라)·S2(API·자동발급) 위에 사람이 조작하는 표면을 얹는다. 그 전까지는 구매자가 신청할 곳도,
관리자가 발급을 볼 곳도 없었다.
발견한 구조적 구멍(S3 착수 시 실측): 주문 생성 POST body 는 템플릿 소유 파일(_checkout_summary.json)의
리터럴 객체다. 슬롯에 주입된 모듈 폼이 _local 에 값을 써도 그 body 에 실릴 방법이 없다 —
결제 버튼에 id 가 없어 overlay 로 타겟할 수 없고, inject_props 는 컴포넌트 props 전용이라
actions[].params.body 에 도달하지 못한다. 그대로 두면 구매자가 신청해도 주문에 아무 기록이 남지 않는다.
→ PO 결정: body 를 통짜 표현식으로 바꾸고 확장 병합 칸(...(_local.checkoutExtraPayload ?? {})) 한 개를 연다.
템플릿은 현금영수증을 알지 않고, 모듈은 그 칸에만 쓴다. 확장 미주입 시 payload 는 전환 전과 동일하다.
발견한 사전 결함(본 세션에서 수정):
· 환경설정 저장이 조용히 무효였다 — StoreEcommerceSettingsRequest 에 3키 규칙이 없어
validated() 가 떨궜다. 관리자가 저장해도 아무 일도 일어나지 않았다.
· payment 응답에 cash_receipt_provider 가 없어, 주문상세 현금영수증 카드가 영원히 렌더될 수 없었다.
· payment 응답에 refund_bank_* 4키가 없어, 취소 모달이 주문 시 입력된 계좌를 프리필할 수 없었다.
· _payment.json 루트에 id 가 없어 KG 플러그인의 overlay 주입(세금내역·영수증 링크·에스크로 버튼)이
조용히 no-op 이었다. 본 이슈가 부수 복구한다(R1).
실서버(Playwright MCP) 실측에서만 드러난 결함 — 유닛 테스트는 전부 green 이었다:
· 무통장 주문의 결제 상태는 waiting_deposit 이 아니라 ready 다. 카드 상태머신이 waiting_deposit 만
매칭해 모든 무통장 주문에서 카드 본문이 비었다. 픽스처가 내가 넣어준 값이라 유닛으로는 잡히지 않았다.
· 관리자 apiCall 3종(발급·발급취소·재발급)에 auth_required 가 없어 Bearer 미첨부 → 전부 401.
· 발급 버튼과 재발급 버튼의 노출 조건이 배타적이지 않아 FAILED 상태에서 둘 다 떴다.
· 이력 표가 원시값을 노출했다 — 취소 행에 "발급 완료", 실패 행 일시 "-"(issued_at null), issue/FAILED.
환불계좌는 PG·현금영수증 프로바이더와 무관한 순수 주문 정보이므로 템플릿이 직접 소유한다(PO 결정).
취소 시점 갱신은 레이아웃이 아니라 cancelOrderHandlers.ts 가 수행한다 — 취소 POST body 를
그 핸들러가 조립하기 때문이다.
axis_notes:
payment_status_of_dbank: |
무통장 주문 생성 직후 payment_status 는 ready 다(PaymentStatusEnum::isAwaitingDeposit() 는
ready·waiting_deposit 양쪽에 true). 입금 대기 상태를 판정하는 모든 표면은 두 값을 함께 매칭해야 한다.
axes:
surface: # 사람이 조작하는 표면
- checkout # W-1 체크아웃 신청 폼 (모듈 주입)
- mypage_order # W-2 유저 주문상세 카드 (모듈 주입)
- admin_order # W-3 관리자 주문상세 카드 + 발급 모달
- admin_cancel_modal # W-4 취소 모달 환불계좌
- admin_settings # W-5 환경설정 (프로바이더 · 배송비 과세 · 자진발급)
payment_method: # 8종 전수 — 현금영수증 대상은 dbank 뿐(D4). 나머지 7종은 "숨김" 이 검증 대상
- dbank # 무통장 → 슬롯/카드 렌더
- vbank # 가상계좌 → 슬롯 미렌더, 환불계좌는 렌더
- card
- bank
- phone
- point
- deposit
- free
provider_state: # 발급 프로바이더 설정 상태
- configured # 슬롯/카드 렌더
- not_configured # 슬롯/카드 전체 미렌더 (DOM 카운트 0)
receipt_type:
- income # 소득공제용 → 발급수단 2종
- expense # 지출증빙용 → 발급수단 3종 (사업자등록번호 포함)
identifier_type: # 용도 × 식별번호 5조합 (D10)
- phone # 양쪽 용도 유효
- card # 양쪽 용도 유효
- business # 지출증빙 전용 + 체크섬
- self_issue # 소득공제 전용 (지출증빙 조합은 거부)
payment_status: # 입금 대기를 표현하는 값이 둘이다 — 실서버 결함 ①의 축
- ready # 무통장 주문 생성 직후의 실제 값
- waiting_deposit # 가상계좌 발급 후의 값
- paid
card_state: # 주문상세 카드 상태머신 5종
- hidden # ① 무통장 아님 / 프로바이더 미설정
- awaiting_deposit # ② 입금 전 → 안내만
- issuable # ③ 입금완료 + 미발급 → 발급 버튼
- issued # ④ 발급완료 → 영수증 링크
- reissue_failed # ⑤ 취소 성공 + 재발급 실패 → 경고 배지
actor: # 권한 축 — 관리자 전용 액션 분리
- admin # 발급 · 발급취소 · 수동 재발급
- member # 발급만 (취소·재발급 버튼 부재)
- guest # 발급만 (X-Guest-Order-Token)
refund_bank_input: # 환불계좌 입력 상태 (부분 입력 거부)
- empty # 미입력
- complete # 3필드 전부
- partial_account_only # 계좌번호만
- partial_bank_only # 은행만
- partial_missing_holder # 예금주 누락
refund_bank_requirement: # 필수 여부 판정 (D13)
- vbank_paid_required # 가상계좌 + 입금완료 → 필수 (미입력 422)
- vbank_waiting_optional # 가상계좌 + 입금 전 → 선택
- dbank_optional # 무통장 → 선택 (관리자 수동 이체 참조)
- card_hidden # 카드·간편결제 → 필드 자체 숨김
shipping_fee_tax_policy: # 배송비 과세 3정책 (D3) — 백엔드 Enum 과 값 일치
- proportional # 안분 (기본)
- taxable # 전액 과세
- follow_main_item # 주된 재화 기준
responsive: # §6-0 반응형 규약 — portable 단독
- desktop
- portable # 0~1023px (mobile + tablet)
locale:
- ko
- en
e2e_browser: # S2 가 이월한 E2E axis
- chromium
cross_product:
- [payment_method, provider_state] # dbank 외 7종에서 슬롯 미렌더 (M10 — 샘플 금지)
- [receipt_type, identifier_type] # 용도 × 식별번호 5조합 (M14)
- [refund_bank_input, refund_bank_requirement] # 부분 입력 × 필수 판정
- [card_state, actor] # 상태머신 × 권한 (유저에겐 취소·재발급 없음)
- [surface, responsive] # 신설 UI 전부 portable 오버라이드
- [payment_status, card_state] # ready·waiting_deposit 양쪽에서 ② 안내가 떠야 한다
effects:
# 확장 병합 칸 (구조적 계약)
- checkout_body_is_single_expression # body 통짜 표현식 전환
- checkout_body_legacy_keys_unchanged # 전환 전후 기존 11키 산출값 동일
- checkout_body_extra_payload_merged # 확장 칸 기입 시 payload 편입
- checkout_body_empty_extra_payload_is_noop # 확장 미주입 시 키 집합·값 동일
- checkout_extra_payload_can_override_keys # spread 가 뒤 → 확장은 자기 도메인 키만 쓴다(계약)
# 체크아웃 신청 폼 (W-1)
- checkout_slot_renders_only_inside_dbank_block # 슬롯이 dbank 블록 내부 → 별도 if 불필요
- checkout_slot_hidden_when_provider_unset # 프로바이더 미설정 시 DOM 카운트 0
- checkout_fields_mount_on_request_toggle # 신청 체크 시 0 → 3 필드
- checkout_purpose_switch_resets_invalid_identifier # 소득공제 전환 시 business → phone 리셋
- checkout_identifier_type_options_by_purpose # 소득공제 2종 / 지출증빙 3종
- checkout_identifier_cleared_on_type_change # 발급수단 변경 시 번호 초기화
# 주문 생성 저장 (FormRequest 4단계 사슬)
- checkout_cash_receipt_request_persisted # is_cash_receipt_requested + 용도 + 수단
- checkout_cash_receipt_identifier_masked_and_encrypted # 평문=마스킹, 암호화 컬럼=원본
- checkout_expense_with_phone_allowed # D10 — 지출증빙 + 휴대폰 허용
- checkout_expense_with_self_issue_rejected_422 # 자진발급번호는 소득공제 전용
- checkout_business_number_checksum_rejected_422
- checkout_requested_without_subkeys_rejected_422
- checkout_no_order_artifact_on_validation_failure # 검증 실패 시 주문 부산물 0
# 환불계좌 (템플릿 소유 — 확장 칸 미경유)
- checkout_refund_bank_persisted
- checkout_refund_bank_partial_input_rejected_422
- checkout_refund_bank_empty_allowed
- checkout_refund_bank_name_resolved_from_code # 은행코드 → 은행명 조회 저장
# 유저 주문상세 카드 (W-2)
- mypage_card_hidden_when_not_dbank_or_no_provider
- mypage_card_state_machine_five_states
- mypage_issue_uses_member_or_guest_endpoint # user/orders/{id} vs guest/orders/{orderNumber}
- mypage_no_cancel_or_reissue_action_for_user # 관리자 전용 액션 부재
# 관리자 주문상세 (W-3)
- admin_card_renders_in_payment_iteration_context
- admin_issue_cancel_reissue_actions_present
- admin_reissue_visible_only_on_failed_history
- admin_issue_modal_setstate_before_closemodal # 순서 역전 시 상태 잔존
- admin_cash_receipt_history_listed # 발급/취소 이력 최신순
- admin_history_collapsed_by_default # 이력이 있어도 기본 접힘
- admin_history_toggle_is_per_payment_row # 결제행마다 독립 토글 (payIdx)
- admin_history_toggle_announces_state # 아이콘 방향 + aria-expanded
# 취소 모달 환불계좌 (W-4)
- cancel_refund_bank_visible_only_for_vbank_dbank
- cancel_refund_bank_required_for_paid_vbank_422
- cancel_refund_bank_optional_before_deposit
- cancel_refund_bank_optional_for_dbank
- cancel_refund_bank_partial_input_rejected_422
- cancel_refund_bank_persisted_to_payment_row # 환불 실행 전에 컬럼 갱신
- cancel_refund_bank_omitted_when_untouched # 미입력 시 키 미전송 → 주문 시 계좌 보존
- cancel_refund_bank_prefilled_from_order # 주문 시 입력값 프리필
# 환경설정 (W-5)
- settings_cash_receipt_provider_persisted # rules 누락 시 validated() 가 떨궈 저장 무효
- settings_provider_empty_means_disabled
- settings_shipping_fee_tax_policy_persisted
- settings_shipping_fee_tax_all_three_policies
- settings_invalid_shipping_fee_tax_policy_rejected_422
- settings_cash_receipt_self_issue_persisted
- settings_self_issue_default_off # D14
- settings_available_providers_exposed # 후보 목록 응답 포함
# 응답 표면
- payment_resource_exposes_cash_receipt_provider # 없으면 카드가 영원히 미렌더
- payment_resource_exposes_type_label
- payment_resource_exposes_refund_bank_fields # 없으면 취소 모달 프리필 불가 (실서버 결함 ③)
- order_detail_eager_loads_cash_receipts # whenLoaded 가드 → 미로드 시 키 자체 부재
# 실서버 실측에서만 드러난 결함 — 유닛은 전부 green 이었다. 회귀 pin.
- card_awaiting_deposit_matches_ready_status # 결함 ① dbank 실제 값은 ready
- card_awaiting_deposit_matches_waiting_deposit_status
- admin_cash_receipt_apicalls_require_auth # 결함 ② auth_required 누락 → 401
- admin_issue_button_hidden_on_failed_state # 결함 ④ 발급·재발급 버튼 동시 노출
- history_transaction_type_label_localized # 결함 ⑤ 원시값 issue 노출
- history_issue_status_label_localized # 결함 ⑤ 원시값 FAILED 노출
- history_cancel_row_does_not_say_issued # 결함 ⑤ 취소 행에 "발급 완료"
- history_occurred_at_falls_back_to_created_at # 결함 ⑥ 실패 행 issued_at null → "-"
# 반응형 규약 (§6-0)
- new_ui_uses_portable_only # mobile/tablet 혼용 금지
- new_ui_has_no_tailwind_breakpoint # md:/lg: 로 마크업 구조 분기 금지
- portable_override_applies_below_1024_on_render # 실렌더: 세로 스택 전환 (F5)
- portable_override_absent_at_1024_on_render # 실렌더: 1024px 부터 base 복귀
# 데이터 위생 (감사 지적 C8, PO 결정 2026-07-10)
- cash_receipt_request_not_persisted_for_non_dbank # 발급 불가 주문에 암호문 미보관
# 자진발급 설정 안내 (감사 지적 W-5)
- self_issue_links_to_nts_mandatory_industry_guide # 의무발행 업종 판단 경로 제공
# 부수 복구
- kg_user_order_show_injection_restored # order_payment_info_panel id 부여 (R1)
test_files:
# 백엔드
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CheckoutCashReceiptAndRefundBankTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CancelOrderRefundBankTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsCashReceiptTest.php
# 모듈 프론트엔드
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptUi.test.tsx
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptResponsiveRender.test.tsx
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/cancelOrderHandlers.test.ts
# 템플릿 (sirsoft-basic)
- templates/_bundled/sirsoft-basic/__tests__/layouts/checkoutSummaryBodyPayload.test.tsx
- templates/_bundled/sirsoft-basic/__tests__/layouts/checkoutCashReceiptSlot.test.tsx
- templates/_bundled/sirsoft-basic/__tests__/layouts/mypageOrderPaymentPanel.test.tsx
- templates/_bundled/sirsoft-basic/__tests__/layouts/shop-guest-checkout-entry.test.tsx
# 응답 표면 회귀 (실서버 결함 ③)
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Resources/CashReceiptResourceTest.php
# E2E (S2 이월분)
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/checkout-cash-receipt.spec.ts
@@ -1,7 +1,8 @@
# audit:allow test-scenario-coverage reason: |
# audit:allow test-scenario-coverage:test-files reason: |
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
# base 통화 정책 axis 는 PHPUnit(OrderProcessingService/buildCurrencySnapshot/OrderAdjustmentService/
# StoreEcommerceSettingsRequest)·Playwright 가 커버하며 green 이다.
# test-files 면제 사유: OrderAdjustmentRefundSnapshotTest / mp08-base-currency-guard.spec.ts 미작성 (#421 이월)
feature: base 통화 정책 + 변경 가드 + 결제/통지/부분환불 base 기준 (A2 — 신규)
description: |
@@ -1,7 +1,8 @@
# audit:allow test-scenario-coverage reason: |
# audit:allow test-scenario-coverage:test-files reason: |
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
# 정가 공통 베이스 axis 는 PHPUnit(BaseOrderItemResource 경유 Cart/Checkout)·Vitest 레이아웃·
# Playwright 가 커버하며 green 이다.
# test-files 면제 사유: mp08-list-price.spec.ts 미작성 (#421 이월)
feature: 정가/할인율 공통 베이스 — 장바구니·주문서 (U5·U4②)
description: |
@@ -1,9 +1,10 @@
# audit:allow test-scenario-coverage reason: |
# audit:allow test-scenario-coverage:test-files reason: |
# cross product 자동 전개는 audit 실행 환경에 js-yaml 이 없어 fallback YAML 파서가
# nested axes 배열을 읽지 못하는 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도
# audit:allow 처리됨). 통화 표시 환산의 백엔드 axis 조합은 PHPUnit(HasMultiCurrencyPrices/
# ProductOptionResource), 셀렉터·가격 바인딩은 Vitest 레이아웃/핸들러, 드롭다운 전환은
# Playwright E2E 가 커버하며 모두 green 이다.
# test-files 면제 사유: mp08-currency-switch.spec.ts 미작성 (#421 이월)
feature: 다통화 표시 환산 + 셀렉터 노출 + JPY 포맷 (A1·U11·U4③)
description: |
@@ -1,7 +1,8 @@
# audit:allow test-scenario-coverage reason: |
# audit:allow test-scenario-coverage:test-files reason: |
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
# 유저별 영속 통화 axis 는 PHPUnit(EcommerceUserProfile/Repository/통화 저장 컨트롤러)·
# Vitest(설정 UI)·Playwright(설치 게이트)가 커버하며 green 이다.
# test-files 면제 사유: mp08-install-gate.spec.ts 미작성 (#421 이월)
feature: 유저별 영속 통화 — user-profile 확장 + 4곳 설정 (A3·A5 — 신규)
description: |
@@ -39,4 +39,4 @@ effects:
- valid_input_preserved # 정상 입력 비파괴
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/CouponBasicValidationTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponBasicValidationTest.php
@@ -30,4 +30,4 @@ effects:
- exclude_intersect_returns_false # exclude 교집합 → 미적용
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/User/CouponCategoryApplicabilityTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Services/CouponCategoryApplicabilityTest.php
@@ -31,4 +31,4 @@ effects:
- download_coupon_regression_DL # downloadCoupon 추출 후 DL- 코드 동일
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/CouponDirectIssueTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponDirectIssueTest.php
@@ -29,4 +29,4 @@ effects:
- created_by_field_matches_creator # created_by → creator 매칭
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/CouponSearchFieldTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponSearchFieldTest.php
@@ -34,4 +34,4 @@ effects:
- include_entry_passes # include 1건 이상 → 통과
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/CouponTargetScopeValidationTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponTargetScopeValidationTest.php
@@ -1,9 +1,10 @@
# audit:allow test-scenario-coverage reason: |
# audit:allow test-scenario-coverage:test-files reason: |
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 nested axes 를 읽지 못하는
# 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도 audit:allow). 본 변경은
# 고시 무한스크롤 response.data.data 바인딩 정정 + toggle-active 라우트/컨트롤러/Service
# (전용 활동로그) + 드롭다운 active_only 필터를 PHPUnit Feature + Vitest 레이아웃 렌더링이
# 커버하며 green.
# test-files 면제 사유: product-notice-list-toggle.test.tsx 미작성 (#421 이월)
feature: 상품정보제공고시 무한스크롤/활성토글/드롭다운 필터 (A9)
description: |
@@ -1,8 +1,9 @@
# audit:allow test-scenario-coverage reason: |
# audit:allow test-scenario-coverage:test-files reason: |
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 nested axes 를 읽지 못하는
# 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도 audit:allow). 본 변경은
# 배송정책 목록 empty_state if 경계 정비 + 상품폼 /active endpoint + activeList 응답 필드 보강
# + 비활성 부여 정책 union 표시/안내를 PHPUnit Feature + Vitest 레이아웃 렌더링이 커버하며 green.
# test-files 면제 사유: shipping-policy-list-empty / product-form-shipping-active.test.tsx 미작성 (#421 이월)
feature: 배송정책 목록 빈 화면 단일화 + 상품폼 활성 정책 노출 (A11)
description: |
@@ -40,5 +40,5 @@ effects:
- backend_no_options_keeps_price # 옵션 미보유 미변경
test_files:
- modules/_bundled/sirsoft-ecommerce/resources/js/handlers/__tests__/productOptionHandlers.test.ts
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/productOptionHandlers.test.ts
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductServiceOptionsTest.php
@@ -72,4 +72,4 @@ test_files:
- templates/_bundled/sirsoft-basic/src/handlers/__tests__/productOptionsAdditional.test.ts
- templates/_bundled/sirsoft-basic/src/__tests__/layouts/shopAdditionalOptions.test.tsx
# 세션 C E2E
- templates/_bundled/sirsoft-basic/tests/Playwright/specs/shop/additional-options.spec.ts
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/additional-options.spec.ts
@@ -1,11 +1,11 @@
{
"schema_version": "1.0",
"generated_at": "2026-08-06T02:43:43+00:00",
"generated_at": "2026-08-06T03:32:02+00:00",
"generator": "g7 vendor-bundle:build",
"target": "module:sirsoft-ecommerce",
"composer_json_sha256": "cf5460aed4b90364c33889e3bd479eeb87c05b8f4b1ee94886575e14dcf441d1",
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
"zip_sha256": "3c84d3216050b9ecdb49ebb8b457275d221b2b0c8369a608a20377b611fecafb",
"zip_sha256": "a475756e4c92ad685d324cacb83beab2c8f94e5d29d8b33bf64dd63b12180f67",
"zip_size": 435548,
"package_count": 1,
"php_requirement": "^8.2",
Binary file not shown.
@@ -99,9 +99,9 @@ sub_flows:
- script_without_preblocker_attr_blocked_by_backup_line
test_files:
- resources/js/__tests__/blocker-domains.test.ts
- resources/js/__tests__/blocker.test.ts
- resources/js/__tests__/preblocker.test.ts
- plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/blocker-domains.test.ts
- plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/blocker.test.ts
- plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/preblocker.test.ts
notes: |
본 매니페스트는 SSoT 로 작성되었으며, audit 룰 test-scenario-coverage 의 docblock 마킹
@@ -72,13 +72,13 @@ effects:
- migration_up_after_down_is_idempotent
test_files:
- tests/Unit/Services/GdprPolicyVersionServiceTest.php
- tests/Feature/Api/Admin/GdprAdminSettingsControllerTest.php
- tests/Feature/Api/Admin/GdprAdminPolicyVersionControllerTest.php
- tests/Feature/Installation/GdprPolicyVersionMigrationSmokeTest.php
- tests/Unit/Services/GdprConsentServiceTest.php
- tests/Feature/Api/Public/GdprCookieConsentControllerTest.php
- tests/Feature/Api/User/GdprConsentControllerTest.php
- plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprPolicyVersionServiceTest.php
- plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Admin/GdprAdminSettingsControllerTest.php
- plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Admin/GdprAdminPolicyVersionControllerTest.php
- plugins/_bundled/sirsoft-gdpr/tests/Feature/Installation/GdprPolicyVersionMigrationSmokeTest.php
- plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprConsentServiceTest.php
- plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php
- plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/User/GdprConsentControllerTest.php
related_docs:
- docs/backend/service-repository.md
@@ -54,9 +54,9 @@
{
"type": "click",
"handler": "setState",
"target": "local",
"params": {
"target": "_local.paymentErrorMessage",
"value": null
"paymentErrorMessage": null
}
},
{
@@ -129,5 +129,5 @@ scope:
- 코어 password_reset 훅에 verification_token 전달 추가 (별도 결함, 본 작업 무관)
test_files:
- tests/Feature/Listeners/AssertNoDuplicateInicisIdentityTest.php
- tests/Feature/Identity/InicisIdentityProviderBindingTest.php
- plugins/_bundled/sirsoft-verification_kginicis/tests/Feature/Listeners/AssertNoDuplicateInicisIdentityTest.php
- plugins/_bundled/sirsoft-verification_kginicis/tests/Feature/Identity/InicisIdentityProviderBindingTest.php
@@ -4,10 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.4] - 2026-07-19
## [1.1.0] - 2026-08-06
### Added
- 주문서의 무통장입금·가상계좌 결제 영역에 환불 계좌(은행·계좌번호·예금주) 입력란을 추가했습니다. 주문을 취소할 때 환불받을 계좌를 미리 남겨둘 수 있으며, 입력하지 않아도 주문할 수 있습니다. 다만 세 칸 중 하나라도 입력하면 나머지도 입력해야 합니다.
- 주문서의 무통장입금 영역에 현금영수증 신청 폼이 표시될 수 있도록 확장 영역을 마련했습니다. 신청 폼 자체는 쇼핑몰 기능이 제공합니다.
- 주문 상세의 결제 정보 영역에 현금영수증 발급 내역이 표시될 수 있도록 확장 영역을 마련했습니다.
- 회원가입 화면에 휴대폰번호·전화번호 입력란을 추가했습니다. 두 항목 모두 선택 사항입니다.
### Changed
@@ -16,6 +19,7 @@
### Fixed
- 주문 상세의 결제 정보 영역에 결제 플러그인이 제공하는 공급가액·부가세·영수증 링크·에스크로 구매확정 버튼이 표시되지 않던 문제를 수정했습니다.
- 총 건수를 끝까지 세지 못한 목록에서 페이지 이동 막대가 사라지거나 "1페이지뿐" 으로 잘못 표시되던 문제를 수정했습니다. 마이페이지의 주문·마일리지·위시리스트·알림함, 게시판 목록, 상품 리뷰·문의, 내가 쓴 글 목록이 해당합니다.
- 검색 결과의 탭 배지가 끝까지 세지 못한 건수를 정확한 숫자처럼 보여주던 문제를 수정했습니다. 이제 그 경우 숫자 뒤에 "이상" 표시가 붙습니다.
@@ -57,7 +61,7 @@
### Changed
- 옵션 담기 방식 개선에 따라 이 템플릿은 이커머스 모듈 1.0.5 이상이 필요합니다.
- 이커머스 모듈 최소 요구 버전을 1.1.0 으로 상향했습니다. 옵션 담기 방식 개선과 주문서 환불 계좌·현금영수증 확장 영역 표시에 해당 버전 이상이 필요합니다.
- 검색 결과가 아주 많아 총 건수를 정확히 셀 수 없는 경우, "N건 이상" 으로 표시하고 검색어를 좁히면 정확한 건수를 볼 수 있다는 안내를 함께 보여 줍니다. 이때 마지막 페이지로 바로 뛰는 버튼은 감춰지고 다음 페이지 이동은 그대로 됩니다.
- 페이지네이션에서 처음/마지막 버튼을 따로 켜고 끌 수 있게 했습니다.
- 목록의 총 건수 정확도 표시를 사용하기 위해 이 템플릿은 이제 코어 7.0.6 이상이 필요합니다.
@@ -0,0 +1,147 @@
/**
* @file checkoutCashReceiptSlot.test.tsx
* @description 체크아웃 현금영수증 확장 슬롯 + 환불계좌 입력 필드 구조 검증 (#454 S3).
*
* 설계 계약:
* - 현금영수증 신청 폼은 템플릿이 슬롯(extension_point)만 열고 이커머스 모듈이 주입한다.
* 슬롯이 dbank 블록 **내부**에 있으므로 결제수단이 무통장일 때만 렌더된다 → 별도 if 불필요.
* - 환불계좌 3필드는 템플릿이 직접 소유한다(PG·현금영수증 프로바이더 비종속, 은행 목록은
* 이미 템플릿이 가진 paymentSettings 를 재사용). dbank·vbank 양쪽 블록에 존재한다.
* - 반응형은 responsive.portable 단독. 신설 노드의 className 에 Tailwind breakpoint(md:/lg:)를
* 쓰면 레이아웃 편집기의 디바이스 미리보기(overrideWidth)가 깨지므로 금지한다.
*/
import { describe, it, expect } from 'vitest';
import checkoutPaymentJson from '../../layouts/partials/shop/_checkout_payment.json';
/** if 표현식에 해당 결제수단이 걸린 최상위 블록 */
const blockFor = (method: string) =>
(checkoutPaymentJson.children as any[]).find(
(c) => typeof c.if === 'string' && c.if.includes(`'${method}'`)
);
const dbankBlock = blockFor('dbank');
const vbankBlock = blockFor('vbank');
/** 노드 트리 전체를 평탄화 */
function flatten(node: any, out: any[] = []): any[] {
if (node == null || typeof node !== 'object') return out;
out.push(node);
for (const value of Object.values(node)) {
if (Array.isArray(value)) value.forEach((v) => flatten(v, out));
else if (value && typeof value === 'object') flatten(value, out);
}
return out;
}
const findById = (root: any, id: string) => flatten(root).find((n) => n.id === id);
describe('체크아웃 현금영수증 확장 슬롯', () => {
it('dbank 블록과 vbank 블록이 존재해야 한다', () => {
expect(dbankBlock, '무통장 블록').toBeDefined();
expect(vbankBlock, '가상계좌 블록').toBeDefined();
});
it('슬롯은 dbank 블록의 직계 자식이어야 한다 (결제수단 게이트를 블록이 대신함)', () => {
const slot = (dbankBlock.children as any[]).find(
(c) => c.type === 'extension_point' && c.name === 'shop_checkout_cash_receipt_slot'
);
expect(slot, 'dbank 블록 직계의 현금영수증 슬롯').toBeDefined();
expect(slot.default).toEqual([]);
});
it('슬롯에 별도 if 게이트가 없어야 한다 (dbank 블록 내부이므로 중복)', () => {
const slot = (dbankBlock.children as any[]).find(
(c) => c.type === 'extension_point' && c.name === 'shop_checkout_cash_receipt_slot'
);
expect(slot.if).toBeUndefined();
});
it('vbank 블록에는 현금영수증 슬롯이 없어야 한다 (D4 — 가상계좌는 PG 가 자동 발급)', () => {
const slots = flatten(vbankBlock).filter((n) => n.type === 'extension_point');
expect(slots.map((s) => s.name)).not.toContain('shop_checkout_cash_receipt_slot');
});
it('템플릿은 현금영수증 도메인 필드를 직접 그리지 않는다 (모듈 소유)', () => {
const raw = JSON.stringify(checkoutPaymentJson);
expect(raw).not.toContain('cash_receipt_type');
expect(raw).not.toContain('cashReceiptIdentifier');
});
});
describe('환불계좌 입력 필드 (템플릿 직접 소유)', () => {
const cases: Array<[string, string, any]> = [
['dbank', 'checkout_refund_bank_dbank', dbankBlock],
['vbank', 'checkout_refund_bank_vbank', vbankBlock],
];
for (const [method, containerId, block] of cases) {
describe(`${method} 블록`, () => {
const container = findById(block, containerId);
it('환불계좌 컨테이너가 존재해야 한다', () => {
expect(container, `${containerId}`).toBeDefined();
});
it('은행 Select + 계좌번호 + 예금주 3필드를 가져야 한다', () => {
const names = flatten(container)
.map((n) => n.props?.name)
.filter(Boolean);
expect(names).toContain('refund_bank_code');
expect(names).toContain('refund_bank_account');
expect(names).toContain('refund_bank_holder');
});
it('3필드가 각각 _local.refundBank* 에 setState 해야 한다', () => {
const targets = flatten(container)
.flatMap((n) => n.actions ?? [])
.filter((a: any) => a.handler === 'setState')
.map((a: any) => Object.keys(a.params).filter((k) => k !== 'target'))
.flat();
expect(targets).toEqual(
expect.arrayContaining(['refundBankCode', 'refundBankAccount', 'refundBankHolder'])
);
});
it('은행 Select 는 paymentSettings 의 banks 를 {value,label} 로 변환해야 한다', () => {
const select = flatten(container).find((n) => n.name === 'Select');
expect(select, '은행 Select').toBeDefined();
// valueKey/labelKey prop 금지 — computed/표현식으로 변환해야 한다
expect(select.props.valueKey).toBeUndefined();
expect(select.props.labelKey).toBeUndefined();
expect(select.props.options).toContain('order_settings?.banks');
expect(select.props.options).toContain('value:');
expect(select.props.options).toContain('label:');
});
it('신설 노드는 responsive.portable 만 쓰고 md:/lg: breakpoint 를 쓰지 않는다 (§6-0)', () => {
const nodes = flatten(container);
const breakpoints = nodes.filter((n) => /(^|\s)(md|lg|xl|2xl):/.test(n.props?.className ?? ''));
expect(breakpoints, 'Tailwind breakpoint 사용 노드').toEqual([]);
const responsiveKeys = nodes.flatMap((n) => Object.keys(n.responsive ?? {}));
expect(responsiveKeys.length, 'responsive 오버라이드가 하나 이상').toBeGreaterThan(0);
expect([...new Set(responsiveKeys)], 'portable 단독 (mobile/tablet 혼용 금지)').toEqual([
'portable',
]);
});
it('2열 그리드가 portable 에서 1열로 접힌다', () => {
const grid = flatten(container).find((n) => (n.props?.className ?? '').includes('grid-cols-2'));
expect(grid, '2열 그리드').toBeDefined();
expect(grid.responsive.portable.props.className).toContain('grid-cols-1');
});
it('다크모드 variant 가 함께 지정되어야 한다', () => {
const withBg = flatten(container).filter((n) =>
/(^|\s)(bg-|text-|border-)/.test(n.props?.className ?? '')
);
expect(withBg.length).toBeGreaterThan(0);
for (const n of withBg) {
expect(n.props.className, `dark: variant 누락 → ${n.props.className}`).toContain('dark:');
}
});
});
}
});
@@ -0,0 +1,230 @@
/**
* @file checkoutSummaryBodyPayload.test.tsx
* @description 주문 생성 POST body 의 확장 병합 칸(checkoutExtraPayload) 계약 검증.
*
* 배경 (#454 S3):
* 슬롯에 주입된 모듈 폼이 _local 에 값을 써도, body 가 템플릿 소유 리터럴 객체이면
* 그 값은 서버에 도달하지 않는다. 모듈은 body 줄을 추가할 수단이 없다
* (결제 버튼 id 부재 → overlay 불가 / inject_props 는 actions[].params.body 미도달).
* → body 를 통짜 표현식으로 두고 확장 병합 칸 1개를 연다.
*
* 평가 경로 변화:
* 전환 전 = ActionDispatcher.resolveParams 가 body 를 재귀하며 키마다 evaluateExpression
* 전환 후 = body 가 단일 문자열 → evaluateExpression 1회
* → 두 경로의 산출값 동등성을 고정한다 (특히 dbank 삼항 · guest_lookup_password null 분기 ·
* expected_total_amount 숫자형 · shipping_memo custom 분기).
*/
import { describe, it, expect } from 'vitest';
import { DataBindingEngine } from '@core/template-engine/DataBindingEngine';
import checkoutSummaryJson from '../../layouts/partials/shop/_checkout_summary.json';
/** 객체 트리에서 조건을 만족하는 첫 노드를 깊이우선 탐색 */
function findNode(node: any, predicate: (n: any) => boolean): any {
if (node == null || typeof node !== 'object') return undefined;
if (predicate(node)) return node;
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const item of value) {
const found = findNode(item, predicate);
if (found !== undefined) return found;
}
} else if (value && typeof value === 'object') {
const found = findNode(value, predicate);
if (found !== undefined) return found;
}
}
return undefined;
}
/** 주문 생성 POST apiCall 노드 */
const orderCall = findNode(
checkoutSummaryJson,
(n) =>
n.handler === 'apiCall' &&
typeof n.target === 'string' &&
n.target.includes('/user/orders') &&
n.params?.method === 'POST'
);
const engine = new DataBindingEngine();
/** body 가 통짜 표현식일 때 실제 런타임 평가 (resolveParams 의 문자열 분기와 동일) */
function evalBody(ctx: Record<string, any>): Record<string, any> {
const raw: string = orderCall.params.body;
return engine.evaluateExpression(raw.slice(2, -2), ctx);
}
/** 전환 이전 동작을 재현하는 기준 payload — 이 테스트가 진실의 기준(SSoT)이다. */
function expectedLegacy(ctx: any): Record<string, any> {
const { checkoutData, _computed, _local, _global } = ctx;
return {
temp_order_id: checkoutData?.data?.temp_order_id,
orderer: _computed?.ordererDefaults,
shipping: _local?.shipping,
payment_method: _computed?.selectedPaymentMethod,
shipping_memo:
_local?.shippingMemo === 'custom' ? _local?.shippingMemoCustom : _local?.shippingMemo,
depositor_name: _local?.depositorName ?? _computed?.ordererDefaults?.name ?? '',
dbank:
_computed?.selectedPaymentMethod === 'dbank'
? {
bank_code: _local?.selectedDbank?.bank_code,
account_number: _local?.selectedDbank?.account_number,
account_holder: _local?.selectedDbank?.account_holder,
}
: null,
expected_total_amount: checkoutData?.data?.calculation?.summary?.final_amount ?? 0,
save_shipping_address: _local?.saveShippingAddress ?? false,
guest_lookup_password: _global?.currentUser?.uuid ? null : (_local?.guestLookupPassword ?? ''),
guest_lookup_password_confirmation: _global?.currentUser?.uuid
? null
: (_local?.guestLookupPasswordConfirmation ?? ''),
refund_bank: _local?.refundBankCode
? {
bank_code: _local?.refundBankCode,
account_number: _local?.refundBankAccount ?? null,
holder: _local?.refundBankHolder ?? null,
}
: null,
};
}
const LEGACY_KEYS = Object.keys(expectedLegacy({}));
const baseCtx = (over: Record<string, any> = {}) => ({
checkoutData: {
data: {
temp_order_id: 'TMP-1',
calculation: { summary: { final_amount: 53000 } },
},
},
_computed: {
ordererDefaults: { name: '홍길동', phone: '01012345678', email: 'a@b.c' },
selectedPaymentMethod: 'dbank',
},
_local: {
shipping: { recipient_name: '홍길동' },
shippingMemo: 'custom',
shippingMemoCustom: '문 앞에 두세요',
selectedDbank: { bank_code: '004', account_number: '110-123', account_holder: '시르소프트' },
saveShippingAddress: true,
guestLookupPassword: 'pw12345678',
guestLookupPasswordConfirmation: 'pw12345678',
},
_global: { currentUser: null },
...over,
});
describe('주문 생성 body — 확장 병합 칸 계약', () => {
it('주문 생성 POST apiCall 노드가 존재해야 한다', () => {
expect(orderCall, '주문 생성 POST apiCall 노드').toBeDefined();
});
it('body 는 통짜 표현식이어야 한다 (확장 칸 spread 를 담기 위함)', () => {
expect(typeof orderCall.params.body).toBe('string');
expect(orderCall.params.body).toContain('checkoutExtraPayload');
});
describe('전환 동등성 — 기존 키 산출값 보존', () => {
const cases: Array<[string, Record<string, any>]> = [
['dbank + 비회원 (기본)', baseCtx()],
[
'card + 회원 — dbank=null · guest 비밀번호 null 분기',
baseCtx({
_computed: {
ordererDefaults: { name: '김철수', phone: '', email: '' },
selectedPaymentMethod: 'card',
},
_global: { currentUser: { uuid: 'user-uuid-1' } },
}),
],
[
'shipping_memo 비-custom 분기',
baseCtx({ _local: { ...baseCtx()._local, shippingMemo: 'door', shippingMemoCustom: '무시됨' } }),
],
[
'depositor_name 미입력 → ordererDefaults.name 폴백',
baseCtx({ _local: { ...baseCtx()._local, depositorName: undefined } }),
],
[
'초기 진입 (빈 _local · calculation 없음)',
{
checkoutData: { data: { temp_order_id: null, calculation: null } },
_computed: { ordererDefaults: undefined, selectedPaymentMethod: 'vbank' },
_local: {},
_global: {},
},
],
];
for (const [name, ctx] of cases) {
it(`[${name}] 통짜 평가 결과가 전환 전 산출값과 같다`, () => {
expect(evalBody(ctx)).toEqual(expectedLegacy(ctx));
});
}
it('expected_total_amount 는 숫자형을 보존한다 (문자열 보간 금지)', () => {
expect(typeof evalBody(baseCtx()).expected_total_amount).toBe('number');
});
});
describe('확장 병합 칸 (checkoutExtraPayload)', () => {
it('모듈 비활성(칸 미주입) 시 payload 가 전환 전과 키·값 모두 동일하다', () => {
const ctx = baseCtx();
const body = evalBody(ctx);
expect(Object.keys(body).sort()).toEqual(LEGACY_KEYS.sort());
expect(body).toEqual(expectedLegacy(ctx));
});
it('칸에 기입한 키가 payload 에 편입되고 기존 키는 불변이다', () => {
const ctx = baseCtx();
(ctx._local as Record<string, any>).checkoutExtraPayload = {
cash_receipt_requested: true,
cash_receipt_type: 'income',
cash_receipt_identifier_type: 'phone',
cash_receipt_identifier: '01012345678',
};
const body = evalBody(ctx);
expect(body.cash_receipt_requested).toBe(true);
expect(body.cash_receipt_type).toBe('income');
expect(body.cash_receipt_identifier_type).toBe('phone');
expect(body.cash_receipt_identifier).toBe('01012345678');
for (const key of LEGACY_KEYS) {
expect(body[key]).toEqual(expectedLegacy(ctx)[key]);
}
});
it('확장 칸은 기존 키를 덮어쓸 수 없어야 한다 — spread 가 뒤에 오므로 덮어쓰기가 가능함을 명시 고정', () => {
const ctx = baseCtx();
(ctx._local as Record<string, any>).checkoutExtraPayload = { payment_method: 'HIJACKED' };
// 현재 계약: 확장 칸이 뒤에 spread 되므로 덮어쓴다.
// 이 동작을 '알고 있는 것'으로 고정한다 — 모듈은 자기 네임스페이스 키만 써야 한다.
expect(evalBody(ctx).payment_method).toBe('HIJACKED');
});
});
describe('환불계좌 (템플릿 직접 소유 — 확장 칸 미경유)', () => {
it('미입력 시 refund_bank 는 null 이다', () => {
expect(evalBody(baseCtx()).refund_bank).toBeNull();
});
it('입력 시 3필드가 그대로 실린다', () => {
const ctx = baseCtx({
_local: {
...baseCtx()._local,
refundBankCode: '004',
refundBankAccount: '110-123-456789',
refundBankHolder: '홍길동',
},
});
expect(evalBody(ctx).refund_bank).toEqual({
bank_code: '004',
account_number: '110-123-456789',
holder: '홍길동',
});
});
});
});
@@ -0,0 +1,74 @@
/**
* @file mypageOrderPaymentPanel.test.tsx
* @description 유저 주문상세 결제 패널의 확장 앵커 검증 (#454 S3).
*
* 두 가지를 고정한다:
* 1. 루트 Div 의 id = "order_payment_info_panel"
* KG 플러그인(resources/extensions/user_order_show.json)이 이 id 를 target_id 로 지정하고
* mypageOrderShowInjector.ts 가 getElementById 로 찾는다. 그동안 이 노드에 id 가 없어
* 주입이 조용히 no-op 이었다 — 본 이슈가 부수 복구한다(R1). id 를 지우면 KG 세금내역·
* 영수증 링크·에스크로 확인 버튼이 한꺼번에 사라진다.
* 2. 말단 extension_point "mypage_order_cash_receipt_slot"
* 이커머스 모듈이 현금영수증 카드를 주입하는 자리.
*/
import { describe, it, expect } from 'vitest';
import paymentPartialJson from '../../layouts/partials/mypage/orders/_payment.json';
import kgUserOrderShowJson from '../../../../../plugins/_bundled/sirsoft-pay_kginicis/resources/extensions/user_order_show.json';
function flatten(node: any, out: any[] = []): any[] {
if (node == null || typeof node !== 'object') return out;
out.push(node);
for (const value of Object.values(node)) {
if (Array.isArray(value)) value.forEach((v) => flatten(v, out));
else if (value && typeof value === 'object') flatten(value, out);
}
return out;
}
describe('유저 주문상세 결제 패널 — 확장 앵커', () => {
it('루트 Div 에 KG 가 기대하는 id 가 부여되어야 한다 (R1 no-op 주입 복구)', () => {
expect(paymentPartialJson.id).toBe('order_payment_info_panel');
});
it('KG overlay 의 target_id 와 실제 앵커 id 가 일치해야 한다', () => {
const injection = (kgUserOrderShowJson as any).injections[0];
expect(injection.target_id).toBe(paymentPartialJson.id);
// append_child = 패널 내부 말단에 붙는다 → 앵커가 컨테이너여야 한다
expect(injection.position).toBe('append_child');
expect(Array.isArray(paymentPartialJson.children)).toBe(true);
});
it('현금영수증 확장 슬롯이 패널 말단에 존재해야 한다', () => {
const slots = flatten(paymentPartialJson).filter((n) => n.type === 'extension_point');
expect(slots.map((s) => s.name)).toContain('mypage_order_cash_receipt_slot');
const slot = slots.find((s) => s.name === 'mypage_order_cash_receipt_slot');
expect(slot.default).toEqual([]);
});
it('슬롯은 결제 정보 목록의 마지막 자식이어야 한다 (결제수단 행 아래)', () => {
const infoList = (paymentPartialJson.children as any[]).find(
(c) => (c.props?.className ?? '').includes('space-y-3')
);
const last = infoList.children[infoList.children.length - 1];
expect(last.type).toBe('extension_point');
expect(last.name).toBe('mypage_order_cash_receipt_slot');
});
it('템플릿은 현금영수증 도메인 필드를 직접 그리지 않는다 (모듈 소유)', () => {
// 슬롯 이름/주석에는 'cash_receipt' 가 들어간다(정상). 금지 대상은 도메인 값 바인딩이다.
const bindings = flatten(paymentPartialJson)
.filter((n) => n.type !== 'extension_point')
.flatMap((n) => [n.text, ...Object.values(n.props ?? {})])
.filter((v): v is string => typeof v === 'string');
for (const b of bindings) {
expect(b, `템플릿이 현금영수증 필드를 직접 바인딩 → ${b}`).not.toContain('cash_receipt');
}
});
it('id 부여가 기존 responsive.portable 규약을 깨지 않는다', () => {
expect(Object.keys(paymentPartialJson.responsive)).toEqual(['portable']);
});
});
@@ -17,6 +17,7 @@
*/
import { describe, it, expect } from 'vitest';
import { DataBindingEngine } from '@core/template-engine/DataBindingEngine';
import purchaseCardJson from '../../layouts/partials/shop/detail/_purchase_card.json';
import cartSummaryJson from '../../layouts/partials/shop/_cart_summary.json';
import loginJson from '../../layouts/auth/login.json';
@@ -174,11 +175,27 @@ describe('결제하기 엔드포인트 분기 - _checkout_summary (Issue #55)',
});
it('body 에 비회원 조회 비밀번호 + 확인이 비로그인일 때만 값으로 전송되어야 한다', () => {
const body = ordersApiCall.params?.body ?? {};
// 회원이면 null, 비회원이면 _local.guestLookupPassword 값
expect(body.guest_lookup_password).toContain('_global.currentUser?.uuid');
expect(body.guest_lookup_password).toContain('_local.guestLookupPassword');
expect(body.guest_lookup_password_confirmation).toContain('_local.guestLookupPasswordConfirmation');
// body 는 통짜 표현식이다(#454 확장 병합 칸). 표현식 문자열을 substring 으로 훑는 대신
// 실제로 평가해 "회원=null / 비회원=입력값" 동작 자체를 고정한다.
const engine = new DataBindingEngine();
const evalBody = (currentUser: unknown) =>
engine.evaluateExpression(String(ordersApiCall.params.body).slice(2, -2), {
checkoutData: { data: { temp_order_id: 'TMP-1', calculation: null } },
_computed: { ordererDefaults: {}, selectedPaymentMethod: 'card' },
_local: {
guestLookupPassword: 'pw12345678',
guestLookupPasswordConfirmation: 'pw12345678',
},
_global: { currentUser },
});
const guest = evalBody(null);
expect(guest.guest_lookup_password).toBe('pw12345678');
expect(guest.guest_lookup_password_confirmation).toBe('pw12345678');
const member = evalBody({ uuid: 'user-uuid-1' });
expect(member.guest_lookup_password).toBeNull();
expect(member.guest_lookup_password_confirmation).toBeNull();
});
});
@@ -489,7 +489,12 @@
"payment_order_not_found": "Order not found.",
"payment_generic_error": "An error occurred during payment. Please try again.",
"coupon_download_btn": "Download Coupons",
"guest_token_issue_failed": "Could not prepare guest order lookup. Please re-verify with your phone and password when viewing your order."
"guest_token_issue_failed": "Could not prepare guest order lookup. Please re-verify with your phone and password when viewing your order.",
"refund_bank_title": "Refund Account (used on cancellation, optional)",
"refund_bank_select": "Select bank",
"refund_bank_account_placeholder": "Account number (no hyphens)",
"refund_bank_holder_placeholder": "Account holder",
"refund_bank_partial_notice": "If you fill in any one of the three fields, the others are required."
},
"order_complete": {
"page_title": "Order Complete",
@@ -489,7 +489,12 @@
"payment_order_not_found": "주문을 찾을 수 없습니다.",
"payment_generic_error": "결제 처리 중 오류가 발생했습니다. 다시 시도해 주세요.",
"coupon_download_btn": "쿠폰 다운로드",
"guest_token_issue_failed": "비회원 조회 인증 준비에 실패했습니다. 주문 조회 시 휴대폰과 비밀번호로 다시 확인해 주세요."
"guest_token_issue_failed": "비회원 조회 인증 준비에 실패했습니다. 주문 조회 시 휴대폰과 비밀번호로 다시 확인해 주세요.",
"refund_bank_title": "환불 계좌 (주문 취소 시 사용, 선택)",
"refund_bank_select": "은행 선택",
"refund_bank_account_placeholder": "계좌번호 (- 없이 입력)",
"refund_bank_holder_placeholder": "예금주",
"refund_bank_partial_notice": "셋 중 하나라도 입력하면 나머지도 입력해야 합니다."
},
"order_complete": {
"page_title": "주문 완료",
@@ -3,8 +3,10 @@
"is_partial": true,
"description": "주문 상세 - 결제 정보"
},
"_comment_id": "id 는 확장의 overlay 앵커다. KG 플러그인의 resources/extensions/user_order_show.json 이 target_id 로 이 이름을 지정하고 mypageOrderShowInjector.ts 가 getElementById 로 찾는데, 그동안 이 노드에 id 가 없어 주입이 조용히 실패하고 있었다(#454 에서 부수 복구).",
"type": "basic",
"name": "Div",
"id": "order_payment_info_panel",
"props": { "className": "bg-white dark:bg-gray-800 rounded-lg p-6" },
"responsive": {
"portable": {
@@ -26,7 +28,7 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "credit-card", "className": "w-5 h-5 text-gray-700 dark:text-gray-300" }
"props": { "name": "credit-card", "className": "text-xl text-gray-700 dark:text-gray-300" }
},
{
"type": "basic",
@@ -135,7 +137,7 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "ticket", "className": "w-3.5 h-3.5 text-red-400 dark:text-red-500" }
"props": { "name": "ticket", "className": "text-sm text-red-400 dark:text-red-500" }
},
{
"type": "basic",
@@ -174,7 +176,7 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "ticket", "className": "w-3 h-3 text-red-400 dark:text-red-500" }
"props": { "name": "ticket", "className": "text-xs text-red-400 dark:text-red-500" }
},
{
"type": "basic",
@@ -209,7 +211,7 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "ticket", "className": "w-3.5 h-3.5 text-red-400 dark:text-red-500" }
"props": { "name": "ticket", "className": "text-sm text-red-400 dark:text-red-500" }
},
{
"type": "basic",
@@ -248,7 +250,7 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "ticket", "className": "w-3 h-3 text-red-400 dark:text-red-500" }
"props": { "name": "ticket", "className": "text-xs text-red-400 dark:text-red-500" }
},
{
"type": "basic",
@@ -283,7 +285,7 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "tag", "className": "w-3.5 h-3.5 text-orange-400 dark:text-orange-500" }
"props": { "name": "tag", "className": "text-sm text-orange-400 dark:text-orange-500" }
},
{
"type": "basic",
@@ -322,7 +324,7 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "tag", "className": "w-3 h-3 text-orange-400 dark:text-orange-500" }
"props": { "name": "tag", "className": "text-xs text-orange-400 dark:text-orange-500" }
},
{
"type": "basic",
@@ -412,7 +414,7 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "ticket", "className": "w-3.5 h-3.5 text-red-400 dark:text-red-500" }
"props": { "name": "ticket", "className": "text-sm text-red-400 dark:text-red-500" }
},
{
"type": "basic",
@@ -619,6 +621,12 @@
}
}
]
},
{
"comment": "현금영수증 카드 확장 슬롯 — 이커머스 모듈이 resources/extensions/mypage_order_cash_receipt.json 으로 주입한다. 결제수단/프로바이더/발급상태에 따른 표시 분기는 주입 측이 if 로 처리한다(무통장이 아니거나 프로바이더 미설정이면 카드 자체 미렌더).",
"type": "extension_point",
"name": "mypage_order_cash_receipt_slot",
"default": []
}
]
}
@@ -58,6 +58,7 @@
},
"props": {
"type": "button",
"data-testid": "checkout-payment-method-{{method.id}}",
"className": "{{_computed.selectedPaymentMethod === method.id ? 'p-4 border-2 border-gray-900 dark:border-white rounded-lg text-left' : 'p-4 border border-gray-300 dark:border-gray-600 rounded-lg text-left hover:border-gray-400 dark:hover:border-gray-500'}}"
},
"responsive": {
@@ -246,6 +247,119 @@
]
}
]
},
{
"comment": "환불 계좌 (선택 입력) — 가상계좌 입금완료 건의 환불은 PG(토스 등)가 refundReceiveAccount 를 필수로 요구한다. 여기서 미입력이면 관리자 취소 모달에서 다시 받는다. 3필드 중 하나라도 채우면 나머지도 필수(CreateOrderRequest::withValidator 의 required_with).",
"type": "basic",
"name": "Div",
"id": "checkout_refund_bank_vbank",
"props": { "className": "mt-4 pt-4 border-t border-blue-200 dark:border-blue-800" },
"responsive": {
"portable": {
"props": { "className": "mt-3 pt-3 border-t border-blue-200 dark:border-blue-800" }
}
},
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" },
"text": "$t:shop.checkout.refund_bank_title"
},
{
"comment": "은행 / 계좌번호 2열 → portable 1열 (마크업 구조 변경이므로 responsive 속성 사용, Tailwind breakpoint 금지)",
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-2 gap-3" },
"responsive": {
"portable": {
"props": { "className": "grid grid-cols-1 gap-3" }
}
},
"children": [
{
"type": "basic",
"name": "Select",
"props": {
"name": "refund_bank_code",
"value": "{{_local.refundBankCode ?? ''}}",
"options": "{{[{ value: '', label: $t('shop.checkout.refund_bank_select') }, ...((paymentSettings.data?.order_settings?.banks ?? []).map(b => ({ value: b.code, label: $localized(b.name) ?? b.code })))]}}",
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"responsive": {
"portable": {
"props": {
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
}
}
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": { "target": "local", "refundBankCode": "{{$event.target.value}}" }
}
]
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "refund_bank_account",
"value": "{{_local.refundBankAccount ?? ''}}",
"placeholder": "$t:shop.checkout.refund_bank_account_placeholder",
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"responsive": {
"portable": {
"props": {
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
}
}
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": { "target": "local", "refundBankAccount": "{{$event.target.value}}" }
}
]
}
]
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "refund_bank_holder",
"value": "{{_local.refundBankHolder ?? ''}}",
"placeholder": "$t:shop.checkout.refund_bank_holder_placeholder",
"className": "w-full mt-3 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"responsive": {
"portable": {
"props": {
"className": "w-full mt-3 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
}
}
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": { "target": "local", "refundBankHolder": "{{$event.target.value}}" }
}
]
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-2" },
"text": "$t:shop.checkout.refund_bank_partial_notice"
}
]
}
]
},
@@ -422,6 +536,125 @@
}
]
},
{
"comment": "환불 계좌 (선택 입력) — 무통장은 PG 를 경유하지 않아 관리자가 수동 이체한다. 여기 입력된 계좌가 그 대상이다. 3필드 중 하나라도 채우면 나머지도 필수(CreateOrderRequest::withValidator 의 required_with).",
"type": "basic",
"name": "Div",
"id": "checkout_refund_bank_dbank",
"props": { "className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-600" },
"responsive": {
"portable": {
"props": { "className": "mt-3 pt-3 border-t border-gray-200 dark:border-gray-600" }
}
},
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" },
"text": "$t:shop.checkout.refund_bank_title"
},
{
"comment": "은행 / 계좌번호 2열 → portable 1열 (마크업 구조 변경이므로 responsive 속성 사용, Tailwind breakpoint 금지)",
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-2 gap-3" },
"responsive": {
"portable": {
"props": { "className": "grid grid-cols-1 gap-3" }
}
},
"children": [
{
"type": "basic",
"name": "Select",
"props": {
"name": "refund_bank_code",
"value": "{{_local.refundBankCode ?? ''}}",
"options": "{{[{ value: '', label: $t('shop.checkout.refund_bank_select') }, ...((paymentSettings.data?.order_settings?.banks ?? []).map(b => ({ value: b.code, label: $localized(b.name) ?? b.code })))]}}",
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"responsive": {
"portable": {
"props": {
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
}
}
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": { "target": "local", "refundBankCode": "{{$event.target.value}}" }
}
]
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "refund_bank_account",
"value": "{{_local.refundBankAccount ?? ''}}",
"placeholder": "$t:shop.checkout.refund_bank_account_placeholder",
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"responsive": {
"portable": {
"props": {
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
}
}
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": { "target": "local", "refundBankAccount": "{{$event.target.value}}" }
}
]
}
]
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "refund_bank_holder",
"value": "{{_local.refundBankHolder ?? ''}}",
"placeholder": "$t:shop.checkout.refund_bank_holder_placeholder",
"className": "w-full mt-3 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
},
"responsive": {
"portable": {
"props": {
"className": "w-full mt-3 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
}
}
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": { "target": "local", "refundBankHolder": "{{$event.target.value}}" }
}
]
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-2" },
"text": "$t:shop.checkout.refund_bank_partial_notice"
}
]
},
{
"comment": "현금영수증 신청 폼 확장 슬롯 — 이커머스 모듈이 resources/extensions/checkout_cash_receipt.json 으로 주입한다. 슬롯이 dbank 블록 내부에 있으므로 결제수단이 무통장일 때만 렌더되며 별도 if 가 필요 없다. 발급 프로바이더는 PG 플러그인이 담당하지만 신청 폼 자체는 PG 비종속이라 모듈이 소유한다(플러그인이 주입하면 두 PG 동시 활성 시 폼이 2개 렌더된다). 주입된 폼은 _local.checkoutExtraPayload 에 자기 필드를 써야 주문 생성 payload 에 실린다 — _checkout_summary.json 의 body 확장 병합 칸 참조.",
"type": "extension_point",
"name": "shop_checkout_cash_receipt_slot",
"default": []
},
{
"comment": "입금 기한 안내 — 가상계좌 안내와 동일 규칙(서버 resolveAutoCancelDays 일치). 빈 문자열/0/음수/비숫자는 기본치 3 으로 되돌린다.",
"type": "basic",
@@ -453,6 +453,7 @@
},
{
"comment": "결제 엔드포인트 — 회원/비회원 공유 단일 endpoint (optional.sanctum). PG 플러그인 fetch 인터셉터가 /user/orders 한 경로만 매칭하므로 비회원도 같은 endpoint 로 호출해 PG 결제창이 정상 노출되도록 한다. 회원/비회원 분기는 백엔드 컨트롤러가 처리.",
"comment_body": "params.body 는 통짜 표현식이다. 말미의 ...(_local.checkoutExtraPayload ?? {}) 는 슬롯에 주입된 모듈/플러그인 폼이 자기 필드를 주문 생성 payload 에 실어 보내기 위한 확장 병합 칸이다. 주입분은 이 body 리터럴을 수정할 수단이 없으므로(결제 버튼에 id 부재 → overlay target 불가, inject_props 는 컴포넌트 props 전용이라 actions[].params.body 미도달) 이 칸이 유일한 경로다. 확장은 자기 도메인 키만 쓴다 — spread 가 뒤에 오므로 기존 키를 덮어쓸 수 있다. 확장 미주입 시 {} 이므로 payload 는 전환 전과 키·값 모두 동일하다. refund_bank 는 템플릿 소유 필드라 확장 칸을 경유하지 않고 직접 기재한다. 회귀 pin: __tests__/layouts/checkoutSummaryBodyPayload.test.tsx",
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/user/orders",
"auth_mode": "optional",
@@ -463,19 +464,7 @@
"params": {
"method": "POST",
"headers": { "X-Cart-Key": "{{_global.cartKey}}" },
"body": {
"temp_order_id": "{{checkoutData.data.temp_order_id}}",
"orderer": "{{_computed.ordererDefaults}}",
"shipping": "{{_local.shipping}}",
"payment_method": "{{_computed.selectedPaymentMethod}}",
"shipping_memo": "{{_local.shippingMemo === 'custom' ? _local.shippingMemoCustom : _local.shippingMemo}}",
"depositor_name": "{{_local.depositorName ?? _computed.ordererDefaults?.name ?? ''}}",
"dbank": "{{_computed.selectedPaymentMethod === 'dbank' ? { bank_code: _local.selectedDbank?.bank_code, account_number: _local.selectedDbank?.account_number, account_holder: _local.selectedDbank?.account_holder } : null}}",
"expected_total_amount": "{{checkoutData.data.calculation?.summary?.final_amount ?? 0}}",
"save_shipping_address": "{{_local.saveShippingAddress ?? false}}",
"guest_lookup_password": "{{_global.currentUser?.uuid ? null : (_local.guestLookupPassword ?? '')}}",
"guest_lookup_password_confirmation": "{{_global.currentUser?.uuid ? null : (_local.guestLookupPasswordConfirmation ?? '')}}"
}
"body": "{{ ({ temp_order_id: checkoutData.data.temp_order_id, orderer: _computed.ordererDefaults, shipping: _local.shipping, payment_method: _computed.selectedPaymentMethod, shipping_memo: _local.shippingMemo === 'custom' ? _local.shippingMemoCustom : _local.shippingMemo, depositor_name: _local.depositorName ?? _computed.ordererDefaults?.name ?? '', dbank: _computed.selectedPaymentMethod === 'dbank' ? { bank_code: _local.selectedDbank?.bank_code, account_number: _local.selectedDbank?.account_number, account_holder: _local.selectedDbank?.account_holder } : null, expected_total_amount: checkoutData.data.calculation?.summary?.final_amount ?? 0, save_shipping_address: _local.saveShippingAddress ?? false, guest_lookup_password: _global.currentUser?.uuid ? null : (_local.guestLookupPassword ?? ''), guest_lookup_password_confirmation: _global.currentUser?.uuid ? null : (_local.guestLookupPasswordConfirmation ?? ''), refund_bank: _local.refundBankCode ? { bank_code: _local.refundBankCode, account_number: _local.refundBankAccount ?? null, holder: _local.refundBankHolder ?? null } : null, ...(_local.checkoutExtraPayload ?? {}) }) }}"
},
"onSuccess": [
{
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sirsoft-basic",
"version": "1.0.4",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sirsoft-basic",
"version": "1.0.4",
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -1,6 +1,6 @@
{
"name": "sirsoft-basic",
"version": "1.0.4",
"version": "1.1.0",
"description": "Gnuboard7 Basic User Template Components - Nexibase Style",
"type": "module",
"main": "dist/components.js",
@@ -5,7 +5,7 @@
"ko": "Basic",
"en": "Basic"
},
"version": "1.0.4",
"version": "1.1.0",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 사용자 템플릿",
@@ -26,7 +26,7 @@
"dependencies": {
"modules": {
"sirsoft-board": ">=1.0.0",
"sirsoft-ecommerce": ">=1.0.5",
"sirsoft-ecommerce": ">=1.1.0",
"sirsoft-page": ">=1.0.0"
},
"plugins": {
+20 -14
View File
@@ -278,35 +278,41 @@ abstract class TestCase extends BaseTestCase
}
/**
* g7_testing DB의 좀비 커넥션을 정리합니다.
* 테스트 DB 의 좀비 커넥션을 정리합니다.
*
* 현재 프로세스의 커넥션은 제외하고,
* g7_testing DB에 연결된 다른 모든 커넥션을 KILL합니다.
* 테스트 DB 에 연결된 다른 모든 커넥션을 KILL 합니다.
*/
private function killStaleTestingConnections(): void
{
// phpunit.xml에서 DB_DATABASE=g7_testing으로 설정됨
$testingDb = config('database.connections.mysql.database');
// config('database.connections.mysql.database') 를 읽지 않는다 — mysql 커넥션은
// read/write 분리 구조라 최상위 'database' 키가 존재하지 않아 항상 null 이 된다.
// null 이면 아래 비교(`($process->db ?? '') === $testingDb`)가 어떤 커넥션과도
// 매칭되지 않아, 좀비 정리가 조용히 전면 무력화된다(예외도 나지 않는다).
// 실제 접속 DB 이름은 커넥션에 직접 묻는다(write 설정이 반영된 값).
try {
$currentId = DB::selectOne('SELECT CONNECTION_ID() as id')->id;
$processes = DB::select('SHOW PROCESSLIST');
$killed = 0;
$testingDb = DB::connection()->getDatabaseName();
foreach ($processes as $process) {
if ($testingDb === '') {
return;
}
$currentId = DB::selectOne('SELECT CONNECTION_ID() as id')->id;
// 정리 결과를 출력하지 않는다 — PHPUnit 은 테스트 실행 중의 예기치 않은 STDOUT/STDERR
// 출력을 오류(`PHPUnit\Framework\Exception`)로 처리한다. 과거에는 대상 DB 판정이
// null 이라 아무것도 KILL 하지 못해 출력이 없었고, 판정을 고치자 그 출력 때문에
// 무관한 테스트들이 무더기로 깨졌다.
// 실제 정리 여부는 tests/Unit/TestCaseKillStaleConnectionsTest 가 행동으로 검증한다.
foreach (DB::select('SHOW PROCESSLIST') as $process) {
if (($process->db ?? '') === $testingDb && $process->Id !== $currentId) {
try {
DB::statement('KILL '.$process->Id);
$killed++;
} catch (\Throwable) {
// 이미 종료된 커넥션은 무시
}
}
}
if ($killed > 0) {
fwrite(STDERR, "\n[TestCase] Killed {$killed} stale g7_testing connection(s)\n");
}
} catch (\Throwable) {
// DB 연결 실패 시 무시 (첫 마이그레이션에서 처리됨)
}
@@ -0,0 +1,113 @@
<?php
namespace Tests\Unit;
use Illuminate\Support\Facades\DB;
use PDO;
use ReflectionMethod;
use Tests\TestCase;
/**
* TestCase::killStaleTestingConnections() 회귀 테스트.
*
* 배경: mysql 커넥션은 read/write 분리 구조(config/database.php)라 최상위 'database'
* 키가 존재하지 않는다. 과거 구현은 `config('database.connections.mysql.database')` 를
* 읽어 항상 null 을 얻었고, 그 결과
*
* if (($process->db ?? '') === $testingDb && ...) // $testingDb === null
*
* 조건이 어떤 커넥션과도 매칭되지 않았다(`'' === null` 도, `'g7_testing' === null` 도 false).
* 즉 좀비 커넥션 정리가 전면 무력화된 채로 조용히 통과하고 있었다. 정리 실패는 예외를
* 던지지 않으므로 아무도 눈치채지 못한다.
*
* 본 테스트는 테스트 DB 에 붙은 별도 커넥션(좀비 대역)을 만들어 두고 메서드를 실행해,
* 그 커넥션이 실제로 KILL 되는지를 확인한다. 판정값이 null 로 회귀하면 좀비가 살아남아 red.
*/
class TestCaseKillStaleConnectionsTest extends TestCase
{
/**
* 테스트 DB 에 붙은 별도 PDO 커넥션을 만듭니다 (좀비 대역).
*
* @return array{0: PDO, 1: int} PDO 와 그 커넥션 ID
*/
private function openZombie(): array
{
$c = config('database.connections.mysql.write');
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s', $c['host'][0] ?? $c['host'], $c['port'], $c['database']),
$c['username'],
$c['password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$id = (int) $pdo->query('SELECT CONNECTION_ID()')->fetchColumn();
return [$pdo, $id];
}
/**
* PROCESSLIST 에 해당 커넥션 ID 가 남아 있는지 확인합니다.
*/
private function connectionAlive(int $id): bool
{
foreach (DB::select('SHOW PROCESSLIST') as $p) {
if ((int) $p->Id === $id) {
return true;
}
}
return false;
}
private function invokeKiller(): void
{
$m = new ReflectionMethod(TestCase::class, 'killStaleTestingConnections');
$m->setAccessible(true);
$m->invoke($this);
}
public function test_좀비는_정리하고_자기_커넥션은_살려둔다(): void
{
$testingDb = DB::connection()->getDatabaseName();
$selfId = (int) DB::selectOne('SELECT CONNECTION_ID() AS id')->id;
[$pdo, $zombieId] = $this->openZombie();
$this->assertTrue($this->connectionAlive($zombieId), '좀비 대역이 만들어져야 한다');
$this->assertNotSame($selfId, $zombieId, '좀비는 자기 커넥션과 달라야 한다');
$this->invokeKiller();
$this->assertFalse(
$this->connectionAlive($zombieId),
'테스트 DB 에 붙은 좀비 커넥션이 정리되어야 한다 '
.'(대상 DB 판정이 null 이면 어떤 행과도 매칭되지 않아 살아남는다)'
);
// 자기 커넥션은 KILL 대상에서 제외되므로 정리 직후에도 쿼리가 가능해야 한다.
// (연결 ID 동일성으로 단정하지 않는다 — 다른 커넥션을 KILL 하면 Laravel 이
// 재연결하며 ID 가 바뀔 수 있고, 그것은 결함이 아니다.)
$this->assertSame(
$testingDb,
DB::selectOne('SELECT DATABASE() AS d')->d,
'정리 직후에도 자기 커넥션으로 후속 쿼리가 가능해야 한다'
);
unset($pdo);
}
public function test_대상_db_판정이_실제_접속_db와_일치한다(): void
{
$target = DB::connection()->getDatabaseName();
$actual = DB::selectOne('SELECT DATABASE() AS d')->d;
$this->assertNotSame('', $target, '대상 DB 가 비면 좀비 정리가 무력화된다');
$this->assertSame($actual, $target, '판정값이 실제 접속 DB 와 달라서는 안 된다');
// 과거 구현이 읽던 최상위 키는 존재하지 않는다(구조적 사실).
$this->assertNull(
config('database.connections.mysql.database'),
'read/write 분리 커넥션에는 최상위 database 키가 없다'
);
}
}

Some files were not shown because too many files have changed in this diff Show More