feat(tosspayments,pay_kginicis,ecommerce,core): 현금영수증 프로바이더 훅 축 + 토스 환불 정합성
현금영수증 발급사를 PG 결제사와 독립적으로 선택할 수 있도록 공용 훅 축 (registered_providers/issue/cancel)에 토스·KG 프로바이더를 등록한다. KG 결제로 받으면서 영수증만 토스로 발급하는 구성이 가능해진다. KG 전용 발행 컨트롤러· 라우트·관리자 패널은 이 축으로 이전하며 제거했다 — 이중 발행 경로를 남기지 않기 위해서다. 토스 환불은 네 가지를 바로잡았다. 전액취소를 부분취소로 보내던 문제(R1), 면세금액 미전달로 복합과세 상점의 부가세가 틀어지던 문제(R2), 가상계좌 환불계좌를 싣지 않아 취소가 실패하던 문제(R3), 에스크로 부분취소를 PG 호출 전에 차단하지 않던 문제(E4). 취소·발급 요청에는 멱등키를 부착해 네트워크 재시도가 국세청에 이중 신고되거나 이중 환불되는 것을 막는다. 코어 활동로그 가드가 \Exception 만 잡아 채널 해석 실패(\Error)를 놓치고 있었다. 결제 입금통보 같은 외부 콜백이 로그 문제만으로 500을 반환하던 문제를 \Throwable 로 수정한다. 활동로그는 부수 기록이므로 그것을 유발한 요청까지 죽여서는 안 된다. 토스 API 서비스는 베이스 \Exception 을 직접 던지고 있어 도메인 예외 (TossPaymentsApiException)를 신설했다. KG의 KgInicisApiException 선례와 동형이며 RuntimeException 상속이라 기존 소비자의 catch 는 그대로 동작한다.
This commit is contained in:
@@ -244,6 +244,7 @@
|
||||
- 화면 구성 파일이 목록을 만들어 내는 과정에서 다국어 이름을 골라 쓸 때, 사이트를 어떤 언어로 보고 있든 항상 한국어 이름이 선택되던 문제를 수정했습니다. 이제 보고 있는 언어의 이름이 표시됩니다. 헤더의 배송국가 선택처럼 화면이 처음 열릴 때 목록을 만들어 두는 곳이 여기에 해당합니다.
|
||||
- 메뉴를 눌러 화면을 이동했을 때, 필요한 정보를 여러 갈래로 나눠 받아오는 화면에서 먼저 도착한 정보가 뒤늦게 도착한 정보에 덮여 사라지는 경우가 있었습니다. 그 탓에 목록이 있어야 할 자리가 간헐적으로 비어 보이고 새로고침하면 정상으로 돌아오곤 했습니다. 이제 어느 정보가 먼저 도착하든 모두 반영됩니다. 이커머스 환경설정의 배송설정 탭에서 배송가능 국가 목록이 비어 보이던 것이 여기에 해당합니다.
|
||||
- 이미 설치를 마친 환경의 설정 파일(`.env`)을 새 서버로 복사해, 데이터베이스가 아직 비어 있는 상태에서 설치를 이어가려 할 때 `php artisan migrate`로 테이블을 만들기도 전에 부팅 단계에서 오류가 나며 중단되던 문제를 수정했습니다. 이제 데이터베이스 작업 명령이 실행되는 동안에는 테이블 존재 여부를 실제로 확인하므로, 기존 서버의 설정을 재사용하는 신규 배포에서도 설치가 정상적으로 진행됩니다. (#41 @GyusoonKim 님께서 제보해주셨습니다.)
|
||||
- 활동 로그 기록에 실패했을 때 그 기록을 유발한 요청까지 함께 실패하던 문제를 수정했습니다. 결제 입금통보 같은 외부 콜백이 활동 로그 문제로 오류를 반환하는 일이 없어집니다.
|
||||
|
||||
## [7.0.2] - 2026-07-08
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ trait ResolvesActivityLogType
|
||||
* properties.extension_origin 에 자동 주입합니다 (loggable 미지정 케이스의
|
||||
* action 라벨 namespace fallback 용).
|
||||
*
|
||||
* @param string $action 액션명 (예: 'user.create')
|
||||
* @param array $context Monolog context 배열
|
||||
* @param string $action 액션명 (예: 'user.create')
|
||||
* @param array $context Monolog context 배열
|
||||
*/
|
||||
protected function logActivity(string $action, array $context): void
|
||||
{
|
||||
@@ -58,9 +58,13 @@ trait ResolvesActivityLogType
|
||||
$context['properties'] = $properties;
|
||||
}
|
||||
|
||||
// 활동 로그는 부수 기록이다 — 실패해도 그것을 유발한 요청(결제 콜백 등)까지
|
||||
// 죽여서는 안 된다. 채널 해석 실패는 Exception 이 아니라 Error 로 오므로
|
||||
// (\Log::channel() 이 null 을 반환하면 "->info() on null" = \Error)
|
||||
// Throwable 로 받아야 가드가 실제로 동작한다.
|
||||
try {
|
||||
Log::channel('activity')->info($action, $context);
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to record activity log', [
|
||||
'action' => $action,
|
||||
'error' => $e->getMessage(),
|
||||
|
||||
@@ -229,7 +229,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
|
||||
> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.
|
||||
|
||||
<!-- @generated:start:api-readme-extensions -->
|
||||
- **확장 수**: 14 · **엔드포인트 수**: 402
|
||||
- **확장 수**: 14 · **엔드포인트 수**: 414
|
||||
|
||||
| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -241,7 +241,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
|
||||
| `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
|
||||
| `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 |
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 |
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
|
||||
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
|
||||
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
|
||||
| `sirsoft-tosspayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-tosspayments/docs/api/README.md) | 2 / 4 |
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.0] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- 현금영수증 발급사 이름(`cash_receipt.provider_name`) 일본어 번역 추가.
|
||||
|
||||
### Removed
|
||||
|
||||
- KG이니시스 전용 현금영수증 발행 화면이 제거되면서 함께 쓰이지 않게 된 발행 오류 안내 3종의 번역을 정리했습니다.
|
||||
|
||||
## [1.0.3] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
return [
|
||||
'errors' => [
|
||||
'cash_receipt_invalid_issue_type' => '発行タイプが正しくありません。',
|
||||
'cash_receipt_missing_issue_number' => '識別番号を入力してください。',
|
||||
'cash_receipt_already_issued' => 'すでに現金領収書が発行された決済です。',
|
||||
'cash_receipt_issue_failed' => '現金領収書の発行に失敗しました。',
|
||||
'order_not_found' => '注文が見つかりません。',
|
||||
'cbt_failed' => '海外決済の処理に失敗しました。',
|
||||
@@ -39,6 +36,9 @@ return [
|
||||
'japan_contact_opening_hours_required' => 'ライブ日本決済画面に表示するお問い合わせ営業時間が必要です。',
|
||||
'replace_sample_value' => 'ライブモードではサンプル値の代わりに実際の契約情報を入力してください。',
|
||||
],
|
||||
'cash_receipt' => [
|
||||
'provider_name' => 'KGイニシス',
|
||||
],
|
||||
'cbt_cvs' => [
|
||||
'simulate_success' => '入金シミュレーションが完了しました。',
|
||||
'simulate_failed' => '入金シミュレーションに失敗しました。',
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_kginicis",
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0-beta.2] - 2026-06-11
|
||||
## [1.0.0] - 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- 현금영수증 발급·취소 안내와 오류 메시지 일본어 번역 추가 — 일본어 로케일에서 현금영수증 발급 결과와 실패 사유가 일본어로 표시됩니다.
|
||||
- 환불 관련 안내(에스크로 부분취소 불가·환불 계좌 누락) 일본어 번역 추가.
|
||||
- 결제 화면 프론트엔드 텍스트 일본어 번역 추가 — 결제 관련 화면이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 주문서형 결제수단(카드·가상계좌·계좌이체·휴대폰·간편결제 9종)의 이름과 설명 일본어 번역 추가 — 일본어 로케일 주문서에서 결제수단이 일본어로 표시됩니다.
|
||||
- 관리자 설정 화면의 결제 방식·가상계좌·에스크로 항목과 입력값 오류 안내 일본어 번역 추가.
|
||||
|
||||
@@ -4,9 +4,16 @@ return [
|
||||
'refund' => [
|
||||
'missing_payment_key' => 'Toss Payments の決済キーが存在しないため、返金処理ができません。',
|
||||
'default_reason' => '顧客リクエストによるキャンセル',
|
||||
'escrow_partial_not_allowed' => 'エスクロー決済は部分キャンセルに対応していません。全額キャンセルのみ可能です。',
|
||||
'missing_refund_account' => '仮想口座決済をキャンセルするには、返金を受け取る口座情報(銀行、口座番号、預金者名)が必要です。',
|
||||
],
|
||||
'settings_validation' => [
|
||||
'vbank_valid_hours_range' => '仮想口座の入金期限は :min~:max時間(最大90日)の間である必要があります。',
|
||||
'use_escrow_invalid' => 'エスクロー使用設定値が正しくありません。',
|
||||
],
|
||||
'cash_receipt' => [
|
||||
'provider_name' => 'Toss Payments',
|
||||
'invalid_order_id' => '現金領収書発行識別子がToss Payments形式(英数字·ハイフン·アンダースコア 6~64文字)に一致していません。',
|
||||
'cancel_reason' => '注文金額変更に伴う再発行',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -73,5 +73,22 @@
|
||||
"order_not_found": "注文が見つかりません。",
|
||||
"payment_failed": "決済に失敗しました。",
|
||||
"non_krw_method": "この決済方法はウォン(KRW)決済にのみ使用できます。"
|
||||
},
|
||||
"user": {
|
||||
"payment_type_label": "決済方法",
|
||||
"escrow_yes": "エスクロー",
|
||||
"receipt_label": "領収書",
|
||||
"receipt_view": "領収書を表示",
|
||||
"cash_receipt_label": "現金領収書",
|
||||
"cash_receipt_view": "現金領収書を表示"
|
||||
},
|
||||
"admin": {
|
||||
"payment_panel_title": "Toss Payments決済情報",
|
||||
"escrow_label": "決済方法",
|
||||
"escrow_yes": "エスクロー",
|
||||
"vbank_label": "仮想口座",
|
||||
"vbank_due_label": "入金期限",
|
||||
"receipt_label": "領収書",
|
||||
"receipt_view": "領収書を表示"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-tosspayments) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-tosspayments) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0-beta.2",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-tosspayments",
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
- 과세·면세 금액 합계에 배송비가 포함됩니다. 그동안 배송비는 과세/면세 어느 쪽에도 집계되지 않아 상품 금액만 합산되었습니다.
|
||||
- 현금영수증 발급 용도 값을 소득공제(income) / 지출증빙(expense)으로 통일했습니다. 기존에 다른 값으로 저장된 주문은 모듈 업데이트 시 자동으로 정리됩니다.
|
||||
- 주문 상세 응답에 현금영수증 정보와 결제 영수증 링크가 포함됩니다.
|
||||
- 결제대행사 환불 연동에 환불 건 정보를 함께 전달합니다. 결제 플러그인이 이 값으로 중복 취소를 방지할 수 있습니다 — 네트워크 재시도로 같은 취소 요청이 두 번 전달되어도 이중 환불되지 않습니다.
|
||||
- 관리자 상품 목록이 상품마다 옵션 전체를 함께 내려주지 않습니다. 목록에는 옵션 개수와 재고 합계만 실리고, 옵션 상세는 행을 펼칠 때 필요한 상품의 것만 한 번에 가져옵니다. 옵션을 많이 쓰는 쇼핑몰일수록 목록을 여는 속도와 전송량이 크게 개선됩니다(상품 100건에 상품당 옵션 20건 기준, 목록 응답 크기 약 94% 감소). 화면에서 보이는 내용과 일괄 변경 적용 범위는 이전과 동일하며, 옵션을 목록 응답에 함께 받으려면 주소 뒤에 `?with_options=1` 을 붙이면 됩니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
- 관리자 주문 목록이 주문마다 주문상품 전체를 함께 내려주지 않습니다. 목록은 대표 상품 1건과 "외 N건" 표기만 사용하므로, 이제 그 1건과 개수만 읽습니다. 한 주문에 상품을 많이 담는 쇼핑몰일수록 목록을 여는 속도와 전송량이 개선됩니다. 화면에 보이는 내용(대표 상품·추가옵션 요약·"외 N건"·부분취소 표시)은 이전과 동일하며, 마이페이지 주문내역은 종전대로 주문상품 전체를 보여줍니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
- 상품 옵션의 표시 순서를 상품 등록/수정 화면과 동일하게 정렬 순서 기준으로 통일했습니다. 이전에는 관리자 상품 목록의 펼친 옵션만 정해진 순서 없이 표시돼 화면마다 순서가 달라 보일 수 있었습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
|
||||
@@ -986,13 +986,17 @@ class OrderCancellationService
|
||||
$orderCurrency = $order->payment->currency ?: ($order->currency_snapshot['order_currency'] ?? null);
|
||||
$refundLocal = $this->resolveOrderCurrencyRefundAmount($result, $orderCurrency);
|
||||
|
||||
// $refund 는 이 환불 시도의 고유 레코드다. PG 리스너가 멱등키(Idempotency-Key)를 조립할 때
|
||||
// 사용한다 — 네트워크 재시도로 같은 취소가 두 번 도달해도 PG 가 중복 청구를 차단한다.
|
||||
// 인자 추가는 하위호환이다: 기존 리스너(5-파라미터)는 초과 인자를 그대로 무시한다.
|
||||
$pgResult = HookManager::applyFilters(
|
||||
'sirsoft-ecommerce.payment.refund',
|
||||
['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null],
|
||||
$order,
|
||||
$order->payment,
|
||||
$refundLocal,
|
||||
$reason
|
||||
$reason,
|
||||
$refund
|
||||
);
|
||||
|
||||
if (! empty($pgResult['success'])) {
|
||||
|
||||
@@ -21,6 +21,16 @@ type EcommerceAuthFixtures = {
|
||||
dashboardToken: string;
|
||||
/** 권한 없는 일반 사용자 토큰 (메뉴 미노출 검증용) */
|
||||
noPermissionToken: string;
|
||||
/** 일반 구매자 토큰 (상점 화면 — 장바구니·주문서 등. 관리자 권한 없음) */
|
||||
userToken: string;
|
||||
/** 구매자 토큰 (마이페이지 — 주문조회·취소요청 등. 관리자 권한 없음) */
|
||||
customerToken: string;
|
||||
/** 주문 조회 전용 권한 토큰 (읽기만 — 수정 게이팅 검증용) */
|
||||
ordersReadToken: string;
|
||||
/** 회원 토큰 (마이페이지 주문상세·리뷰 등. 관리자 권한 없음) */
|
||||
memberToken: string;
|
||||
/** 주문 수정 권한 토큰 (입금확인·상태 전이 검증용) */
|
||||
ordersUpdateToken: string;
|
||||
/** 마일리지 조회 + 수동 관리 권한 보유 토큰 */
|
||||
mileageManageToken: string;
|
||||
/** 마일리지 조회 전용 토큰 (수동 지급/차감 게이팅 검증용) */
|
||||
@@ -29,6 +39,8 @@ type EcommerceAuthFixtures = {
|
||||
productManageToken: string;
|
||||
/** 주문 조회(`orders.read`) 권한 보유 토큰 (주문 상세 표시 검증용) */
|
||||
ordersReadToken: string;
|
||||
/** 주문 조회 + 수정 권한 보유 토큰 (입금확인·취소·현금영수증 발급 검증용) */
|
||||
orderManageToken: string;
|
||||
/** 배송정책 조회+수정 권한 보유 토큰 (배송정책 목록 표시 검증용) */
|
||||
shippingPolicyToken: string;
|
||||
/** 일반 쇼핑 사용자 토큰 (관리자 권한 없음 — 유저 화면 검증용) */
|
||||
@@ -48,6 +60,24 @@ export const test = base.extend<EcommerceAuthFixtures>({
|
||||
// 빈 권한 — 인증만 통과, 어떤 모듈 권한도 없음
|
||||
await use(issueToken());
|
||||
},
|
||||
userToken: async ({}, use) => {
|
||||
// 상점 이용자 — 관리자 권한 없이 인증만 통과한다 (장바구니/주문서는 회원이면 이용 가능).
|
||||
await use(issueToken());
|
||||
},
|
||||
customerToken: async ({}, use) => {
|
||||
// 마이페이지 이용자 — 자기 주문만 다루므로 관리자 권한이 필요 없다.
|
||||
await use(issueToken());
|
||||
},
|
||||
ordersReadToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-ecommerce.orders.read'));
|
||||
},
|
||||
memberToken: async ({}, use) => {
|
||||
// 회원 — 자기 주문/리뷰만 다루므로 관리자 권한이 필요 없다.
|
||||
await use(issueToken());
|
||||
},
|
||||
ordersUpdateToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-ecommerce.orders.read', 'sirsoft-ecommerce.orders.update'));
|
||||
},
|
||||
mileageManageToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-ecommerce.mileage.read', 'sirsoft-ecommerce.mileage.manage'));
|
||||
},
|
||||
@@ -60,6 +90,9 @@ export const test = base.extend<EcommerceAuthFixtures>({
|
||||
ordersReadToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-ecommerce.orders.read'));
|
||||
},
|
||||
orderManageToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-ecommerce.orders.read', 'sirsoft-ecommerce.orders.update'));
|
||||
},
|
||||
shippingPolicyToken: async ({}, use) => {
|
||||
await use(
|
||||
issueToken(
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
* - 무통장 1회 제한 쿠폰은 주문 생성 즉시 USED 차감되어 입금 전까지 재사용 불가(공개#57).
|
||||
*
|
||||
* Select 렌더/disabled 바인딩은 레이아웃 렌더 테스트(adminEcommerceSettingsOrder.test.tsx)가,
|
||||
* 차감 시점 분기·쿠폰 검증·선차감/반환은 PHPUnit(OrderCalculation·CouponUse·CouponRestore·
|
||||
* EcommerceSettingsMileageTiming·MigrateMileageDeductionTiming)가 커버한다. 본 spec 은 브라우저에서
|
||||
* 차감 시점 분기·쿠폰 검증·선차감/반환은 PHPUnit(OrderCalculation, CouponUse, CouponRestore,
|
||||
* EcommerceSettingsMileageTiming, MigrateMileageDeductionTiming)가 커버한다. 본 spec 은 브라우저에서
|
||||
* 설정 disabled 연동 + 주문서 위반 안내 노출만 왕복 검증한다.
|
||||
*
|
||||
* 활성화 전 사전 작업(data-testid 보강):
|
||||
|
||||
+682
@@ -0,0 +1,682 @@
|
||||
/**
|
||||
* 현금영수증 전주기 E2E (#454 S3 이월 → S5).
|
||||
*
|
||||
* checkout-cash-receipt.spec.ts 가 "신청 폼이 렌더·동작하는가" 를 본다면, 이 spec 은
|
||||
* 신청 이후의 **발급 생애주기**를 본다: 입금확인 → 자동발급 → 금액변동 → 전액취소·재발급.
|
||||
*
|
||||
* 단위 테스트가 green 인 상태에서도 실환경에서만 드러난 결함이 실제로 있었다(§13-3 실측):
|
||||
* - 취소 API 에 cancelReason 누락 → 토스가 400 거부 (Http::fake 는 본문을 검증하지 않음)
|
||||
* - 취소 응답의 'c_' 접두 키를 저장 → 영수증이 영원히 "활성" 으로 남아 재발급 미발동
|
||||
* 두 결함 모두 브라우저 + 실 PG 경로에서만 재현된다. 그래서 이 spec 은 PG 응답을 모킹하지 않고
|
||||
* 관리자 화면의 실제 버튼을 눌러 서버가 PG 를 호출하게 한다.
|
||||
*
|
||||
* 발급 결과는 화면 문구가 아니라 서버 상태(주문 상세 API 의 cash_receipt / cash_receipts)로
|
||||
* 단언한다 — 화면 문구는 i18n 으로 바뀌지만 발급 회차·금액·상태는 도메인 사실이다.
|
||||
*
|
||||
* 전제: 관리자 환경설정에 현금영수증 발급사(cash_receipt_provider)가 설정되어 있고 그 PG 의
|
||||
* 테스트 키가 유효해야 한다. 미설정/무효면 발급 자체가 불가하므로 해당 테스트는 skip 한다
|
||||
* (실패로 위장하지 않는다 — 환경 미비와 코드 결함을 구분한다).
|
||||
*
|
||||
* @scenario cash-receipt-provider-and-refund-integrity
|
||||
* @effects amount_change_triggers_cancel_then_reissue,
|
||||
* cancel_delegated_as_full_cancel_without_amount,
|
||||
* order_id_suffixed_with_issue_sequence,
|
||||
* tax_free_amount_sent_with_issue,
|
||||
* issue_response_mapped_to_receipt_key_and_issue_number,
|
||||
* receipt_type_converted_to_provider_specific_value
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
const CART_LOCALE = 'ko';
|
||||
|
||||
const TOGGLE = '#ext_checkout_cash_receipt_toggle';
|
||||
const IDENTIFIER = '#ext_checkout_cash_receipt_identifier';
|
||||
|
||||
type ApiResult = { status: number; body: string };
|
||||
|
||||
/** 앱이 쓰는 것과 동일한 Bearer 헤더로 API 를 호출한다. */
|
||||
async function api(
|
||||
page: Page,
|
||||
path: string,
|
||||
init: { method?: string; body?: unknown } = {}
|
||||
): Promise<ApiResult> {
|
||||
return page.evaluate(
|
||||
async ({ path, init, locale }) => {
|
||||
const res = await fetch(path, {
|
||||
method: init.method ?? 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'Accept-Language': locale,
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
},
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
});
|
||||
return { status: res.status, body: await res.text() };
|
||||
},
|
||||
{ path, init, locale: CART_LOCALE }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문번호로 관리자용 주문 id 를 얻는다.
|
||||
*
|
||||
* 관리자 상세 라우트는 id 바인딩이고, 주문 생성 결과로 손에 쥐는 건 주문번호다.
|
||||
* 유저 주문 상세는 주문번호로 조회되며 id 를 함께 내려준다.
|
||||
*/
|
||||
async function resolveOrderId(page: Page, orderNumber: string): Promise<number> {
|
||||
const res = await api(page, `/api/modules/sirsoft-ecommerce/user/orders/${orderNumber}`);
|
||||
expect(res.status, `주문(${orderNumber}) 조회 실패: ${res.body}`).toBe(200);
|
||||
const id = JSON.parse(res.body)?.data?.id as number;
|
||||
expect(id, '주문 id 를 얻지 못했습니다.').toBeTruthy();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 주문 상세(관리자)를 읽어 현금영수증 상태를 돌려준다. */
|
||||
async function readReceiptState(page: Page, orderId: number) {
|
||||
const res = await api(page, `/api/modules/sirsoft-ecommerce/admin/orders/${orderId}`);
|
||||
expect(res.status, `주문 상세 조회 실패: ${res.body}`).toBe(200);
|
||||
const data = JSON.parse(res.body)?.data ?? {};
|
||||
return {
|
||||
total: Number(data.total_amount ?? 0),
|
||||
active: data.cash_receipt ?? null,
|
||||
history: (data.cash_receipts ?? []) as Array<Record<string, unknown>>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 주문 상세에서 입금확인을 처리한다 (자동발급 리스너의 발화 지점).
|
||||
*
|
||||
* 이미 주문 상세 화면에 있어야 한다.
|
||||
*/
|
||||
async function confirmDeposit(page: Page, amount: number): Promise<void> {
|
||||
await page.getByRole('button', { name: '입금확인' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.waitFor({ state: 'visible' });
|
||||
await dialog.locator('input[type=number]').fill(String(amount));
|
||||
await dialog.locator('input[type=checkbox]').check();
|
||||
await dialog.getByRole('button', { name: '입금확인' }).click();
|
||||
|
||||
// 입금확인 요청은 그 안에서 자동발급 리스너가 **동기적으로** PG 왕복을 수행한다.
|
||||
// 실측상 응답까지 30초를 넘기는 경우가 있어(주문 생성 → 발급 완료 시각차 ~40초),
|
||||
// 30초로 잡으면 "모달이 안 닫힘" 으로 위장된 타임아웃이 난다 — 서버는 정상 처리 중이다.
|
||||
await expect(dialog).toBeHidden({ timeout: 120_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 자진발급(미신청 주문을 국세청 지정번호로 자동발급) 설정을 읽는다.
|
||||
*
|
||||
* 이 설정이 켜져 있으면 "신청하지 않은 주문" 도 입금완료 시 자동발급된다 —
|
||||
* 사후 발급(유저가 나중에 직접 발급)의 전제인 "미발급 주문" 이 만들어지지 않는다.
|
||||
*/
|
||||
async function selfIssueEnabled(page: Page): Promise<boolean> {
|
||||
const res = await api(page, '/api/modules/sirsoft-ecommerce/settings/payment');
|
||||
if (res.status !== 200) return false;
|
||||
return Boolean(JSON.parse(res.body)?.data?.order_settings?.cash_receipt_self_issue);
|
||||
}
|
||||
|
||||
/** 발급사가 설정되어 있는지 — 미설정이면 발급 경로 자체가 존재하지 않는다. */
|
||||
async function cashReceiptProvider(page: Page): Promise<string | null> {
|
||||
const res = await api(page, '/api/modules/sirsoft-ecommerce/settings/payment');
|
||||
if (res.status !== 200) return null;
|
||||
const provider = JSON.parse(res.body)?.data?.order_settings?.cash_receipt_provider;
|
||||
return typeof provider === 'string' && provider !== '' ? provider : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 무통장 + 현금영수증 신청 주문을 체크아웃 화면에서 실제로 만든다.
|
||||
*
|
||||
* 주문 생성은 검증 대상(발급 생애주기)의 전제이지 검증 대상이 아니므로 카트/임시주문은
|
||||
* 사용자가 밟는 API 경로로 만들되, 현금영수증 신청 자체는 화면 폼으로 입력한다.
|
||||
*/
|
||||
async function placeDbankOrderWithCashReceipt(
|
||||
page: Page,
|
||||
identifier: string | null
|
||||
): Promise<string> {
|
||||
const seeded = await page.evaluate(async ({ locale }) => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'Accept-Language': locale,
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
};
|
||||
|
||||
const list = await fetch('/api/modules/sirsoft-ecommerce/products?per_page=30', {
|
||||
headers,
|
||||
}).then((r) => r.json());
|
||||
const candidates = (list?.data?.data ?? []) as Array<{ id: number }>;
|
||||
|
||||
await fetch('/api/modules/sirsoft-ecommerce/cart/all', { method: 'DELETE', headers });
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const detail = await fetch(
|
||||
`/api/modules/sirsoft-ecommerce/products/${candidate.id}`,
|
||||
{ headers }
|
||||
).then((r) => r.json());
|
||||
const options = (detail?.data?.options ?? []) as Array<{
|
||||
option_values_localized?: Record<string, string>;
|
||||
stock_quantity?: number;
|
||||
}>;
|
||||
|
||||
// 부분취소를 만들려면 수량 2 가 필요하다. 재고가 모자란 옵션을 고르면
|
||||
// 주문 생성이 422(재고 부족)로 실패하고, 그 실패가 "현금영수증 결함" 으로 오해된다.
|
||||
const usable = options.find((o) => (o.stock_quantity ?? 0) >= 2);
|
||||
if (!usable) continue;
|
||||
|
||||
const added = await fetch('/api/modules/sirsoft-ecommerce/cart', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
product_id: candidate.id,
|
||||
items: [{ option_values: usable.option_values_localized ?? {}, quantity: 2 }],
|
||||
}),
|
||||
});
|
||||
if (!added.ok) continue;
|
||||
|
||||
const cart = await fetch('/api/modules/sirsoft-ecommerce/cart', { headers }).then((r) =>
|
||||
r.json()
|
||||
);
|
||||
const itemIds = (cart?.data?.items ?? []).map((i: { id: number }) => i.id);
|
||||
const checkout = await fetch('/api/modules/sirsoft-ecommerce/checkout', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ item_ids: itemIds }),
|
||||
});
|
||||
if (checkout.ok) return { ok: true, body: '' };
|
||||
}
|
||||
return { ok: false, body: '담을 수 있는 상품이 없습니다.' };
|
||||
}, { locale: CART_LOCALE });
|
||||
|
||||
expect(seeded.ok, seeded.body).toBe(true);
|
||||
|
||||
await page.goto('/shop/checkout');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// 무통장 결제수단 선택
|
||||
await page.getByTestId('checkout-payment-method-dbank').click();
|
||||
|
||||
// 현금영수증 신청 (소득공제 + 휴대폰번호 — 기본 선택 그대로 사용).
|
||||
// identifier 가 null 이면 신청하지 않는다 — "미신청 주문" 은 사후 발급의 전제다.
|
||||
if (identifier !== null) {
|
||||
await page.locator(TOGGLE).click();
|
||||
await page.locator(IDENTIFIER).fill(identifier);
|
||||
}
|
||||
|
||||
// 나머지 필수값은 앱 상태로 채운다 — 이 spec 의 검증 대상이 아닌 입력이다.
|
||||
// - 입금할 계좌(_local.selectedDbank): 아이콘 기반 커스텀 컨트롤이라 클릭 대상이 불안정하다.
|
||||
// 관리자 환경설정에 등록된 계좌를 그대로 골라 앱 핸들러와 같은 상태를 만든다.
|
||||
// - 주문자 연락처: 회원 프로필에 없으면 빈 값이라 서버가 422 로 거절한다.
|
||||
// - 배송지 주소: 주소검색이 외부 스크립트(다음 우편번호) 팝업이다.
|
||||
const filled = await page.evaluate(async () => {
|
||||
const core = (
|
||||
window as unknown as {
|
||||
G7Core: {
|
||||
state: {
|
||||
getLocal(): Record<string, unknown>;
|
||||
setLocal(v: Record<string, unknown>): void;
|
||||
};
|
||||
};
|
||||
}
|
||||
).G7Core;
|
||||
|
||||
const settings = await fetch('/api/modules/sirsoft-ecommerce/settings/payment', {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
},
|
||||
}).then((r) => r.json());
|
||||
|
||||
const accounts = (settings?.data?.order_settings?.bank_accounts ?? []) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
if (accounts.length === 0) return { ok: false, reason: '등록된 입금 계좌가 없습니다.' };
|
||||
|
||||
const local = core.state.getLocal();
|
||||
const shipping = (local.shipping ?? {}) as Record<string, unknown>;
|
||||
const orderer = (local.orderer ?? {}) as Record<string, unknown>;
|
||||
|
||||
core.state.setLocal({
|
||||
selectedDbank: accounts[0],
|
||||
orderer: { ...orderer, phone: '010-1234-5678' },
|
||||
shipping: {
|
||||
...shipping,
|
||||
recipient_name: '테스트수령인',
|
||||
recipient_phone: '010-1234-5678',
|
||||
zipcode: '06236',
|
||||
address: '서울특별시 강남구 테헤란로 152',
|
||||
address_detail: '10층',
|
||||
},
|
||||
});
|
||||
return { ok: true, reason: '' };
|
||||
});
|
||||
|
||||
expect(filled.ok, filled.reason).toBe(true);
|
||||
|
||||
const submit = page.getByRole('button', { name: /결제하기/ });
|
||||
await expect(submit).toBeEnabled({ timeout: 10_000 });
|
||||
|
||||
// 주문 생성 실패를 "화면이 안 넘어감" 으로 뭉뚱그리면 원인을 알 수 없다.
|
||||
// 서버 응답을 붙잡아 실패 시 본문을 그대로 드러낸다.
|
||||
const orderPost = page.waitForResponse(
|
||||
(r) => r.url().includes('/user/orders') && r.request().method() === 'POST',
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
await submit.click();
|
||||
|
||||
const posted = await orderPost;
|
||||
expect(
|
||||
posted.status(),
|
||||
`주문 생성 실패 (${posted.status()}): ${await posted.text()}`
|
||||
).toBeLessThan(300);
|
||||
|
||||
await page.waitForURL(/\/shop\/orders\/.+\/complete/, { timeout: 30_000 });
|
||||
const orderNumber = page.url().match(/orders\/([^/]+)\/complete/)?.[1];
|
||||
expect(orderNumber, '주문번호를 URL 에서 얻지 못했습니다.').toBeTruthy();
|
||||
return orderNumber as string;
|
||||
}
|
||||
|
||||
test.describe('현금영수증 전주기 (신청 → 입금확인 → 자동발급 → 금액변동 재발급)', () => {
|
||||
// 각 테스트가 실 PG(토스) 왕복을 여러 번 수행한다. 병렬로 돌리면 동시 발급 요청이 겹쳐
|
||||
// 응답 지연이 누적되고, 그 지연이 "모달이 안 닫힘" 같은 UI 결함으로 오인된다.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// 한 테스트가 주문생성 → 입금확인 → PG 발급 → 부분취소 → PG 취소 → PG 재발급을 모두 밟는다.
|
||||
// PG 왕복이 여러 번이라 기본 30초로는 부족하다 (초과 시 모달 대기 실패로 위장된다).
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test('무통장 신청 → 입금확인 → 자동발급 → 영수증 링크 노출', async ({
|
||||
page,
|
||||
orderManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, orderManageToken);
|
||||
await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE);
|
||||
await page.goto('/shop');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const provider = await cashReceiptProvider(page);
|
||||
test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경 — 발급 경로 자체가 없음');
|
||||
|
||||
const orderNumber = await placeDbankOrderWithCashReceipt(page, '01012341234');
|
||||
|
||||
// 주문 조회 → 입금 전에는 발급 이력이 없어야 한다.
|
||||
const orderId = await resolveOrderId(page, orderNumber);
|
||||
|
||||
const before = await readReceiptState(page, orderId);
|
||||
expect(before.active, '입금 전에는 발급된 영수증이 없어야 한다').toBeNull();
|
||||
|
||||
// 관리자 화면에서 입금확인 — 자동발급 리스너가 여기서 발화한다.
|
||||
await page.goto(`/admin/ecommerce/orders/${orderId}`);
|
||||
await confirmDeposit(page, before.total);
|
||||
|
||||
// 자동발급 결과를 서버 상태로 단언한다.
|
||||
await expect
|
||||
.poll(async () => (await readReceiptState(page, orderId)).active?.issue_status, {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe('COMPLETED');
|
||||
|
||||
const after = await readReceiptState(page, orderId);
|
||||
expect(after.active?.provider).toBe(provider);
|
||||
expect(Number(after.active?.amount)).toBe(before.total);
|
||||
expect(after.active?.issue_number, '발급번호가 없다').toBeTruthy();
|
||||
expect(String(after.active?.receipt_url ?? ''), '영수증 URL 이 없다').toMatch(/^https?:\/\//);
|
||||
|
||||
// 식별번호는 마스킹되어야 한다 (원문 노출 금지).
|
||||
expect(String(after.active?.identifier_masked ?? '')).not.toContain('01012341234');
|
||||
|
||||
// 화면에도 링크가 노출된다.
|
||||
await page.reload();
|
||||
await expect(page.locator('a[href^="http"]', { hasText: /영수증/ }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('자동발급 → 부분환불 → 전액취소 후 재발급 (발급 회차 증가)', async ({
|
||||
page,
|
||||
orderManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, orderManageToken);
|
||||
await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE);
|
||||
await page.goto('/shop');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const provider = await cashReceiptProvider(page);
|
||||
test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경');
|
||||
|
||||
const orderNumber = await placeDbankOrderWithCashReceipt(page, '01055556666');
|
||||
const orderId = await resolveOrderId(page, orderNumber);
|
||||
|
||||
const before = await readReceiptState(page, orderId);
|
||||
|
||||
// 입금확인 → 자동발급
|
||||
await page.goto(`/admin/ecommerce/orders/${orderId}`);
|
||||
await confirmDeposit(page, before.total);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readReceiptState(page, orderId)).active?.issue_status, {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe('COMPLETED');
|
||||
|
||||
const issued = await readReceiptState(page, orderId);
|
||||
const firstReceiptId = issued.active?.id;
|
||||
const firstNumber = issued.active?.issue_number;
|
||||
expect(firstReceiptId, '최초 발급 영수증을 식별하지 못했다').toBeTruthy();
|
||||
|
||||
// 부분취소 — 주문상품 1건(수량 2) 중 1 개만 취소해 주문 금액을 낮춘다.
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// 주문상품 행이 렌더될 때까지 기다린다. 주문 데이터는 비동기로 로드되므로
|
||||
// 바로 체크박스를 누르면 헤더(전체선택)만 있는 상태를 건드리게 된다.
|
||||
const rowCheckbox = page.locator('input[type=checkbox]').nth(1);
|
||||
await rowCheckbox.waitFor({ state: 'visible', timeout: 20_000 });
|
||||
await rowCheckbox.click();
|
||||
|
||||
// 화면 문구("N개 선택됨")가 아니라 핸들러가 실제로 읽는 앱 상태를 단언한다.
|
||||
// buildOrderDetailBulkConfirmData 는 _local.selectedProducts 를 읽으며,
|
||||
// 이 값이 비어 있으면 취소할 항목이 없다고 판단해 모달을 열지 않는다.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page.evaluate(() => {
|
||||
const g = (
|
||||
window as unknown as {
|
||||
G7Core?: { state?: { getLocal?(): Record<string, unknown> } };
|
||||
}
|
||||
).G7Core;
|
||||
return ((g?.state?.getLocal?.()?.selectedProducts ?? []) as unknown[]).length;
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// 주문상태 Select 는 네이티브 <select> 가 아니라 커스텀 listbox 다 (Select.tsx).
|
||||
// 옵션 클릭이 합성 change 이벤트를 만들어 _local.batchOrderStatus 를 커밋한다.
|
||||
await page.getByRole('button', { name: '주문상태 변경' }).click();
|
||||
const statusListbox = page.getByRole('listbox').last();
|
||||
await statusListbox.waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await statusListbox.getByRole('option', { name: '주문취소', exact: true }).click();
|
||||
|
||||
await expect(statusListbox).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
// 핸들러(buildOrderDetailBulkConfirmData)는 _local.batchOrderStatus 를 읽는다.
|
||||
// 화면상 드롭다운 라벨이 바뀌어도 이 상태가 커밋되지 않으면 핸들러는 경고 토스트만
|
||||
// 띄우고 조용히 반환한다 — 증상은 "모달이 안 열림" 이라 원인을 오해하기 쉽다.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page.evaluate(() => {
|
||||
const g = (
|
||||
window as unknown as {
|
||||
G7Core?: { state?: { getLocal?(): Record<string, unknown> } };
|
||||
}
|
||||
).G7Core;
|
||||
return (g?.state?.getLocal?.()?.batchOrderStatus ?? null) as string | null;
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe('cancelled');
|
||||
|
||||
const applyButton = page.getByRole('button', { name: '일괄변경' });
|
||||
await expect(applyButton).toBeEnabled({ timeout: 10_000 });
|
||||
await applyButton.click();
|
||||
|
||||
const cancelModal = page.getByRole('dialog');
|
||||
await cancelModal.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
|
||||
// 취소 수량을 1 로 낮춰 부분취소로 만든다 (기본값은 주문 수량 전량).
|
||||
// 전량이면 발급 대상 금액이 0 이 되어 "취소 후 재발급" 이 아니라 "발급 대상 소멸" 이 된다.
|
||||
const qty = cancelModal.locator('input[type=number]').first();
|
||||
await qty.fill('1');
|
||||
|
||||
await cancelModal.locator('select').first().selectOption({ label: '관리자 취소' });
|
||||
await cancelModal.getByRole('button', { name: '주문 취소' }).click();
|
||||
await expect(cancelModal).toBeHidden({ timeout: 60_000 });
|
||||
|
||||
// 금액이 줄었으면 기존 영수증은 전액취소되고 새 금액으로 재발급되어야 한다 (D7).
|
||||
// receipt_key 는 API 가 노출하지 않으므로(자격증명성 식별자) 영수증 행 id 로 판별한다.
|
||||
await expect
|
||||
.poll(async () => (await readReceiptState(page, orderId)).active?.id, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.not.toBe(firstReceiptId);
|
||||
|
||||
const reissued = await readReceiptState(page, orderId);
|
||||
|
||||
expect(reissued.active?.issue_status).toBe('COMPLETED');
|
||||
expect(Number(reissued.active?.amount), '재발급 금액이 취소 후 주문 금액과 달라졌다').toBe(
|
||||
reissued.total
|
||||
);
|
||||
expect(Number(reissued.active?.amount)).toBeLessThan(before.total);
|
||||
expect(reissued.active?.issue_number).not.toBe(firstNumber);
|
||||
|
||||
// 이력에 취소 행이 남아야 한다 — 부분환불이 영수증에 반영되지 않으면
|
||||
// 고객이 실제 결제액과 다른 금액의 영수증을 계속 보유하게 된다.
|
||||
const cancelRow = reissued.history.find((r) => r.transaction_type === 'cancel');
|
||||
expect(cancelRow, '취소 이력이 없다 — 부분취소가 영수증에 반영되지 않았다').toBeTruthy();
|
||||
expect(cancelRow?.issue_status).toBe('COMPLETED');
|
||||
|
||||
// 부분취소인데 영수증을 부분취소하면 안 된다 — 취소 행 금액은 원 발급액 전액이다 (D7).
|
||||
expect(Number(cancelRow?.amount)).toBe(before.total);
|
||||
|
||||
// 이력은 발급 → 취소 → 재발급 3건이어야 한다.
|
||||
expect(reissued.history.length).toBe(3);
|
||||
});
|
||||
|
||||
test('입금 전에는 발급 버튼이 비활성 — 발급 시도가 불가능하다', async ({
|
||||
page,
|
||||
orderManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, orderManageToken);
|
||||
await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE);
|
||||
await page.goto('/shop');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const provider = await cashReceiptProvider(page);
|
||||
test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경');
|
||||
|
||||
const orderNumber = await placeDbankOrderWithCashReceipt(page, '01077778888');
|
||||
const orderId = await resolveOrderId(page, orderNumber);
|
||||
|
||||
await page.goto(`/admin/ecommerce/orders/${orderId}`);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// 입금 전 상태에서는 발급 버튼이 없어야 한다 (입금확인 후에만 발급 가능).
|
||||
await expect(page.getByRole('button', { name: '현금영수증 발급' })).toHaveCount(0);
|
||||
|
||||
// 서버도 같은 판정을 해야 한다 — UI 만 숨기고 API 가 열려 있으면 우회 발급이 가능하다.
|
||||
// 필드명은 FormRequest 규칙(receipt_type)과 일치해야 한다. 틀린 키를 보내면 "미입금 거부"가
|
||||
// 아니라 "필수값 누락"으로 422 가 나서, 가드가 죽어 있어도 테스트는 통과해 버린다.
|
||||
const forced = await api(
|
||||
page,
|
||||
`/api/modules/sirsoft-ecommerce/admin/orders/${orderId}/cash-receipt`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { receipt_type: 'income', identifier_type: 'phone', identifier: '01012341234' },
|
||||
}
|
||||
);
|
||||
expect(forced.status, `입금 전 발급이 서버에서 허용됐다: ${forced.body}`).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// 거부 사유가 "미입금"이어야 한다 — 검증 실패로 인한 422 와 구분한다.
|
||||
expect(
|
||||
JSON.parse(forced.body)?.errors?.error_code,
|
||||
`미입금 가드가 아닌 다른 사유로 거부됐다: ${forced.body}`
|
||||
).toBe('PAYMENT_NOT_PAID');
|
||||
|
||||
const state = await readReceiptState(page, orderId);
|
||||
expect(state.active, '입금 전인데 영수증이 발급되었다').toBeNull();
|
||||
});
|
||||
|
||||
test('미신청 주문 → 입금완료 후 사후 발급 (유저가 직접 발급)', async ({
|
||||
page,
|
||||
orderManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, orderManageToken);
|
||||
await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE);
|
||||
await page.goto('/shop');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const provider = await cashReceiptProvider(page);
|
||||
test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경');
|
||||
|
||||
// 자진발급이 켜져 있으면 미신청 주문도 입금완료 시 국세청 지정번호로 자동발급된다.
|
||||
// 그 경우 "미발급 상태의 입금완료 주문" 자체가 만들어지지 않으므로 사후 발급을 검증할 수 없다.
|
||||
// (자진발급 자체의 동작은 아래 '자진발급' 테스트가 검증한다.)
|
||||
test.skip(
|
||||
await selfIssueEnabled(page),
|
||||
'자진발급이 켜진 환경 — 미신청 주문도 자동발급되어 사후 발급 대상이 생기지 않음'
|
||||
);
|
||||
|
||||
// 신청 없이 무통장 주문 → 자진발급 꺼짐 + 미신청이므로 발급되지 않아야 한다.
|
||||
const orderNumber = await placeDbankOrderWithCashReceipt(page, null);
|
||||
const orderId = await resolveOrderId(page, orderNumber);
|
||||
const before = await readReceiptState(page, orderId);
|
||||
|
||||
await page.goto(`/admin/ecommerce/orders/${orderId}`);
|
||||
await confirmDeposit(page, before.total);
|
||||
|
||||
// 입금완료 후에도 영수증은 없어야 한다 — 신청하지 않았고 자진발급도 꺼져 있으므로.
|
||||
expect(
|
||||
(await readReceiptState(page, orderId)).active,
|
||||
'신청하지 않았고 자진발급도 꺼졌는데 발급되었다'
|
||||
).toBeNull();
|
||||
|
||||
// 이제 사후 발급 — 유저 경로로 발급한다 (입금완료 + 미발급이므로 허용되어야 한다).
|
||||
const issued = await api(
|
||||
page,
|
||||
`/api/modules/sirsoft-ecommerce/user/orders/${orderId}/cash-receipt`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { receipt_type: 'income', identifier_type: 'phone', identifier: '01099998888' },
|
||||
}
|
||||
);
|
||||
expect(issued.status, `사후 발급이 거부됐다: ${issued.body}`).toBe(200);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readReceiptState(page, orderId)).active?.issue_status, {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe('COMPLETED');
|
||||
|
||||
const after = await readReceiptState(page, orderId);
|
||||
expect(Number(after.active?.amount)).toBe(before.total);
|
||||
expect(after.active?.issue_number, '발급번호가 없다').toBeTruthy();
|
||||
expect(String(after.active?.identifier_masked ?? '')).not.toContain('01099998888');
|
||||
|
||||
// 이미 발급된 주문의 재발급 시도는 409 여야 한다 (중복 발급 차단).
|
||||
const again = await api(
|
||||
page,
|
||||
`/api/modules/sirsoft-ecommerce/user/orders/${orderId}/cash-receipt`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { receipt_type: 'income', identifier_type: 'phone', identifier: '01099998888' },
|
||||
}
|
||||
);
|
||||
expect(again.status, `이미 발급된 주문에 중복 발급이 허용됐다: ${again.body}`).toBe(409);
|
||||
expect(JSON.parse(again.body)?.errors?.error_code).toBe('ALREADY_ISSUED');
|
||||
});
|
||||
|
||||
test('자진발급 ON + 미신청 주문 → 국세청 지정번호로 소득공제 자동발급', async ({
|
||||
page,
|
||||
orderManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, orderManageToken);
|
||||
await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE);
|
||||
await page.goto('/shop');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const provider = await cashReceiptProvider(page);
|
||||
test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경');
|
||||
test.skip(!(await selfIssueEnabled(page)), '자진발급이 꺼진 환경 — 이 경로가 존재하지 않음');
|
||||
|
||||
// 신청하지 않은 주문이라도 자진발급이 켜져 있으면 입금완료 시 발급된다.
|
||||
// (현금 결제분을 미발급으로 남기지 않기 위한 제도적 처리 — 국세청 지정번호로 소득공제 발급)
|
||||
const orderNumber = await placeDbankOrderWithCashReceipt(page, null);
|
||||
const orderId = await resolveOrderId(page, orderNumber);
|
||||
const before = await readReceiptState(page, orderId);
|
||||
|
||||
await page.goto(`/admin/ecommerce/orders/${orderId}`);
|
||||
await confirmDeposit(page, before.total);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readReceiptState(page, orderId)).active?.issue_status, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBe('COMPLETED');
|
||||
|
||||
const issued = await readReceiptState(page, orderId);
|
||||
|
||||
// 자진발급은 제도상 소득공제용만 가능하다 (지출증빙 자진발급 불가).
|
||||
expect(issued.active?.receipt_type, '자진발급인데 소득공제가 아니다').toBe('income');
|
||||
expect(Number(issued.active?.amount)).toBe(before.total);
|
||||
expect(issued.active?.issue_number, '발급번호가 없다').toBeTruthy();
|
||||
|
||||
// 국세청 지정번호(0100001234)로 발급되어야 한다 — 마스킹 뒤 4자리로 확인한다.
|
||||
expect(
|
||||
String(issued.active?.identifier_masked ?? ''),
|
||||
'자진발급 지정번호가 아닌 식별번호로 발급되었다'
|
||||
).toContain('1234');
|
||||
});
|
||||
|
||||
test('지출증빙 + 자진발급 지정번호(0100001234) 조합은 거부된다 (422)', async ({
|
||||
page,
|
||||
orderManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, orderManageToken);
|
||||
await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE);
|
||||
await page.goto('/shop');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const provider = await cashReceiptProvider(page);
|
||||
test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경');
|
||||
|
||||
// 입금확인을 하지 않는다 — 용도×식별번호 조합 검증은 FormRequest 단계라
|
||||
// 발급 가드(미입금/기발급)보다 **먼저** 걸린다. 입금까지 진행하면 자진발급 설정에 따라
|
||||
// 이미 발급된 상태가 되어 409(ALREADY_ISSUED)가 먼저 나고, 정작 검증 규칙을 못 건드린다.
|
||||
const orderNumber = await placeDbankOrderWithCashReceipt(page, null);
|
||||
const orderId = await resolveOrderId(page, orderNumber);
|
||||
|
||||
const endpoint = `/api/modules/sirsoft-ecommerce/admin/orders/${orderId}/cash-receipt`;
|
||||
|
||||
// 자진발급 지정번호는 소득공제 전용이다 (지출증빙 자진발급은 제도상 불가).
|
||||
const selfIssueAsExpense = await api(page, endpoint, {
|
||||
method: 'POST',
|
||||
body: { receipt_type: 'expense', identifier_type: 'phone', identifier: '0100001234' },
|
||||
});
|
||||
expect(
|
||||
selfIssueAsExpense.status,
|
||||
`지출증빙 + 자진발급번호가 허용됐다: ${selfIssueAsExpense.body}`
|
||||
).toBe(422);
|
||||
|
||||
// 소득공제 + 사업자등록번호도 거부된다 (INCOME 은 phone/card 만 허용).
|
||||
const incomeWithBusiness = await api(page, endpoint, {
|
||||
method: 'POST',
|
||||
body: { receipt_type: 'income', identifier_type: 'business', identifier: '1234567890' },
|
||||
});
|
||||
expect(
|
||||
incomeWithBusiness.status,
|
||||
`소득공제 + 사업자등록번호가 허용됐다: ${incomeWithBusiness.body}`
|
||||
).toBe(422);
|
||||
|
||||
// 반증: 허용 조합(소득공제 + 자진발급번호)은 검증을 통과해야 한다.
|
||||
// 거부가 "자진발급번호 자체 금지"로 과잉 적용되면 여기서 드러난다.
|
||||
// 미입금 주문이므로 검증 통과 후 발급 가드에 걸려 PAYMENT_NOT_PAID 가 나온다 —
|
||||
// 즉 422 라도 error_code 가 검증 오류가 아닌 발급 가드여야 한다.
|
||||
const selfIssueAsIncome = await api(page, endpoint, {
|
||||
method: 'POST',
|
||||
body: { receipt_type: 'income', identifier_type: 'phone', identifier: '0100001234' },
|
||||
});
|
||||
expect(
|
||||
JSON.parse(selfIssueAsIncome.body)?.errors?.error_code,
|
||||
`소득공제 + 자진발급번호가 검증에서 거부됐다 — 과잉 차단: ${selfIssueAsIncome.body}`
|
||||
).toBe('PAYMENT_NOT_PAID');
|
||||
|
||||
// 거부된 조합이 발급을 남기지 않았는지 확인한다 — 422 를 주고도 발급하면 최악이다.
|
||||
expect(
|
||||
(await readReceiptState(page, orderId)).active,
|
||||
'거부된 조합인데 영수증이 발급되었다'
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
+57
-13
@@ -27,16 +27,12 @@ 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 = {
|
||||
@@ -61,7 +57,7 @@ const IDENTIFIER = '#ext_checkout_cash_receipt_identifier';
|
||||
*/
|
||||
async function seedCheckout(page: Page): Promise<void> {
|
||||
const result = await page.evaluate(
|
||||
async ({ payload, locale }) => {
|
||||
async ({ locale }) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -70,14 +66,62 @@ async function seedCheckout(page: Page): Promise<void> {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
// 담을 상품을 런타임에 찾는다. 상품 ID 를 고정하면 시드가 다른 환경에서
|
||||
// 전부 실패하고, 그 실패가 "현금영수증 폼 결함" 으로 오해된다.
|
||||
const list = await fetch(
|
||||
'/api/modules/sirsoft-ecommerce/products?per_page=30',
|
||||
{ headers }
|
||||
).then((r) => r.json());
|
||||
|
||||
const candidates = (list?.data?.data ?? list?.data ?? []) as Array<{ id: number }>;
|
||||
if (candidates.length === 0) {
|
||||
return { step: 'product-list', status: 0, body: '판매 중인 상품이 없습니다.' };
|
||||
}
|
||||
|
||||
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() };
|
||||
// 상품마다 옵션 구성이 다르므로 상세를 읽어 판매 가능한 첫 옵션을 그대로 쓴다.
|
||||
// option_values_localized 는 서버가 현재 로케일로 평탄화해 내려준 맵이며,
|
||||
// CartService::bulkAddToCart 가 비교하는 형태와 동일하다.
|
||||
let added: Response | null = null;
|
||||
let lastBody = '';
|
||||
for (const candidate of candidates) {
|
||||
const detail = await fetch(
|
||||
`/api/modules/sirsoft-ecommerce/products/${candidate.id}`,
|
||||
{ headers }
|
||||
).then((r) => r.json());
|
||||
|
||||
const options = (detail?.data?.options ?? []) as Array<{
|
||||
option_values_localized?: Record<string, string>;
|
||||
stock_quantity?: number;
|
||||
}>;
|
||||
// 재고가 없는 옵션을 고르면 주문 생성이 422 로 막혀 검증 대상에 닿지도 못한다.
|
||||
const usable = options.find((o) => (o.stock_quantity ?? 0) > 0);
|
||||
if (!usable) continue;
|
||||
|
||||
const res = await fetch('/api/modules/sirsoft-ecommerce/cart', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
product_id: candidate.id,
|
||||
items: [
|
||||
{
|
||||
option_values: usable.option_values_localized ?? {},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
added = res;
|
||||
break;
|
||||
}
|
||||
lastBody = await res.text();
|
||||
}
|
||||
|
||||
if (!added) {
|
||||
return { step: 'cart', status: 422, body: `담을 수 있는 상품 없음: ${lastBody}` };
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -93,7 +137,7 @@ async function seedCheckout(page: Page): Promise<void> {
|
||||
});
|
||||
return { step: 'checkout', status: checkout.status, body: await checkout.text() };
|
||||
},
|
||||
{ payload: CART_ITEM, locale: CART_LOCALE }
|
||||
{ locale: CART_LOCALE }
|
||||
);
|
||||
|
||||
expect(result.status, `${result.step} 단계 실패: ${result.body}`).toBeLessThan(300);
|
||||
|
||||
@@ -6,7 +6,7 @@ use Modules\Sirsoft\Ecommerce\DTO\AppliedPromotions;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\CouponApplication;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\DiscountCodeApplication;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\PromotionsSummary;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* PromotionsSummary 평탄 키 생산 표준 검증 (주문 스냅샷 표준화/MP06)
|
||||
@@ -14,7 +14,7 @@ use PHPUnit\Framework\TestCase;
|
||||
* 주문 생성 시 promotions_applied_snapshot 이 coupon_issue_ids / item_coupons / discount_code
|
||||
* 평탄 키를 항상 포함하도록 표준화한 것을 검증한다. 취소 복원/재계산이 이 평탄 키를 SSoT 로 읽는다.
|
||||
*/
|
||||
class PromotionsSummaryFlatKeysTest extends TestCase
|
||||
class PromotionsSummaryFlatKeysTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 상품 쿠폰 + 주문 쿠폰을 가진 요약 DTO 를 구성합니다.
|
||||
|
||||
+2
-2
@@ -3,14 +3,14 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Database\Seeders;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Database\Seeders\Sample\ProductSeeder;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* ProductSeeder 이미지 색상 필터 테스트
|
||||
*
|
||||
* applyColorFilter 메서드의 GD 기반 색상 필터 로직을 검증합니다.
|
||||
*/
|
||||
class ProductSeederImageTest extends TestCase
|
||||
class ProductSeederImageTest extends ModuleTestCase
|
||||
{
|
||||
private ProductSeeder $seeder;
|
||||
|
||||
|
||||
+24
-25
@@ -10,7 +10,8 @@ use Modules\Sirsoft\Ecommerce\Listeners\EcommerceUserActivityLogListener;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Cart;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* EcommerceUserActivityLogListener 테스트
|
||||
@@ -23,7 +24,7 @@ use Tests\TestCase;
|
||||
*
|
||||
* 모든 메서드가 ActivityLogType::User를 사용하는지 검증합니다.
|
||||
*/
|
||||
class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
class EcommerceUserActivityLogListenerTest extends ModuleTestCase
|
||||
{
|
||||
private EcommerceUserActivityLogListener $listener;
|
||||
|
||||
@@ -34,7 +35,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
parent::setUp();
|
||||
$this->app->instance('request', Request::create('/api/shop/test'));
|
||||
$this->listener = app(EcommerceUserActivityLogListener::class);
|
||||
$this->logChannel = Mockery::mock(\Psr\Log\LoggerInterface::class);
|
||||
$this->logChannel = Mockery::mock(LoggerInterface::class);
|
||||
Log::shouldReceive('channel')
|
||||
->with('activity')
|
||||
->andReturn($this->logChannel);
|
||||
@@ -45,7 +46,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
// getSubscribedHooks
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public function test_getSubscribedHooks_returns_all_hooks(): void
|
||||
public function test_get_subscribed_hooks_returns_all_hooks(): void
|
||||
{
|
||||
$hooks = EcommerceUserActivityLogListener::getSubscribedHooks();
|
||||
|
||||
@@ -65,7 +66,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
// Cart 핸들러 테스트
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public function test_handleCartAfterAdd_logs_activity_with_product_relation(): void
|
||||
public function test_handle_cart_after_add_logs_activity_with_product_relation(): void
|
||||
{
|
||||
$cart = $this->createCartMockWithProduct(1, 'Test Product');
|
||||
$data = ['quantity' => 2];
|
||||
@@ -85,7 +86,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleCartAfterAdd($cart, $data);
|
||||
}
|
||||
|
||||
public function test_handleCartAfterAdd_falls_back_to_product_name_attribute(): void
|
||||
public function test_handle_cart_after_add_falls_back_to_product_name_attribute(): void
|
||||
{
|
||||
$cart = $this->createCartMockWithProductName(2, 'Fallback Name');
|
||||
$data = ['quantity' => 1];
|
||||
@@ -102,7 +103,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleCartAfterAdd($cart, $data);
|
||||
}
|
||||
|
||||
public function test_handleCartAfterAdd_with_empty_product_info(): void
|
||||
public function test_handle_cart_after_add_with_empty_product_info(): void
|
||||
{
|
||||
$cart = $this->createCartMockWithProductName(3, null);
|
||||
|
||||
@@ -117,7 +118,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleCartAfterAdd($cart, []);
|
||||
}
|
||||
|
||||
public function test_handleCartAfterUpdateQuantity_logs_activity(): void
|
||||
public function test_handle_cart_after_update_quantity_logs_activity(): void
|
||||
{
|
||||
$cart = $this->createCartMockWithProduct(4, 'Qty Product');
|
||||
$quantity = 5;
|
||||
@@ -137,7 +138,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleCartAfterUpdateQuantity($cart, $quantity);
|
||||
}
|
||||
|
||||
public function test_handleCartAfterChangeOption_logs_activity(): void
|
||||
public function test_handle_cart_after_change_option_logs_activity(): void
|
||||
{
|
||||
$cart = $this->createCartMockWithProduct(5, 'Option Product');
|
||||
$newProductOptionId = 42;
|
||||
@@ -159,7 +160,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleCartAfterChangeOption($cart, $newProductOptionId, $quantity);
|
||||
}
|
||||
|
||||
public function test_handleCartAfterDelete_logs_activity(): void
|
||||
public function test_handle_cart_after_delete_logs_activity(): void
|
||||
{
|
||||
$cartId = 10;
|
||||
|
||||
@@ -176,7 +177,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleCartAfterDelete($cartId);
|
||||
}
|
||||
|
||||
public function test_handleCartAfterDeleteAll_logs_activity(): void
|
||||
public function test_handle_cart_after_delete_all_logs_activity(): void
|
||||
{
|
||||
$userId = 100;
|
||||
$cartKey = 'user_100_cart';
|
||||
@@ -210,7 +211,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
* description_params 패턴이 검증되었으므로, wishlist 핸들러의 로직(add/remove 분기,
|
||||
* product_name 추출)은 소스코드 리뷰로 보장합니다.
|
||||
*/
|
||||
public function test_handleWishlistAfterToggle_method_exists_and_hooks_registered(): void
|
||||
public function test_handle_wishlist_after_toggle_method_exists_and_hooks_registered(): void
|
||||
{
|
||||
$hooks = EcommerceUserActivityLogListener::getSubscribedHooks();
|
||||
|
||||
@@ -228,7 +229,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
* alias mock의 PHP 프로세스 내 클래스 재정의 제한으로 인해
|
||||
* 훅 등록 및 메서드 존재를 검증합니다.
|
||||
*/
|
||||
public function test_handleUserCouponAfterDownload_method_exists_and_hooks_registered(): void
|
||||
public function test_handle_user_coupon_after_download_method_exists_and_hooks_registered(): void
|
||||
{
|
||||
$hooks = EcommerceUserActivityLogListener::getSubscribedHooks();
|
||||
|
||||
@@ -241,7 +242,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
// Order 핸들러 테스트
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public function test_handleOrderAfterCreate_logs_activity(): void
|
||||
public function test_handle_order_after_create_logs_activity(): void
|
||||
{
|
||||
$order = Mockery::mock(Order::class)->makePartial();
|
||||
$order->forceFill(['id' => 10, 'order_code' => 'ORD-20260326-001']);
|
||||
@@ -263,7 +264,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleOrderAfterCreate($order);
|
||||
}
|
||||
|
||||
public function test_handleOrderAfterCreate_handles_null_order_code(): void
|
||||
public function test_handle_order_after_create_handles_null_order_code(): void
|
||||
{
|
||||
$order = Mockery::mock(Order::class)->makePartial();
|
||||
$order->forceFill(['id' => 11]);
|
||||
@@ -285,7 +286,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
// Order Option 핸들러 테스트
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public function test_handleOrderOptionAfterConfirm_logs_activity(): void
|
||||
public function test_handle_order_option_after_confirm_logs_activity(): void
|
||||
{
|
||||
$order = Mockery::mock(Order::class)->makePartial();
|
||||
$order->forceFill(['id' => 20]);
|
||||
@@ -311,7 +312,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleOrderOptionAfterConfirm($order, $option);
|
||||
}
|
||||
|
||||
public function test_handleOrderOptionAfterConfirm_hooks_registered(): void
|
||||
public function test_handle_order_option_after_confirm_hooks_registered(): void
|
||||
{
|
||||
$hooks = EcommerceUserActivityLogListener::getSubscribedHooks();
|
||||
|
||||
@@ -324,7 +325,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
// 에러 핸들링 테스트
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public function test_logActivity_catches_exception_and_logs_error(): void
|
||||
public function test_log_activity_catches_exception_and_logs_error(): void
|
||||
{
|
||||
$this->logChannel->shouldReceive('info')
|
||||
->once()
|
||||
@@ -375,13 +376,12 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
/**
|
||||
* product 관계가 있는 Cart mock 생성
|
||||
*
|
||||
* @param int $id 장바구니 ID
|
||||
* @param string $productName 상품명
|
||||
* @return Cart
|
||||
* @param int $id 장바구니 ID
|
||||
* @param string $productName 상품명
|
||||
*/
|
||||
private function createCartMockWithProduct(int $id, string $productName): Cart
|
||||
{
|
||||
$product = new \stdClass();
|
||||
$product = new \stdClass;
|
||||
$product->name = $productName;
|
||||
|
||||
$cart = Mockery::mock(Cart::class)->makePartial();
|
||||
@@ -397,9 +397,8 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
/**
|
||||
* product 관계 없이 product_name 속성만 있는 Cart mock 생성
|
||||
*
|
||||
* @param int $id 장바구니 ID
|
||||
* @param string|null $productName 상품명
|
||||
* @return Cart
|
||||
* @param int $id 장바구니 ID
|
||||
* @param string|null $productName 상품명
|
||||
*/
|
||||
private function createCartMockWithProductName(int $id, ?string $productName): Cart
|
||||
{
|
||||
|
||||
+2
-2
@@ -11,12 +11,12 @@ use App\Support\Query\BoundedCount;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SearchProductsListener;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* SearchProductsListener 단위 테스트
|
||||
*/
|
||||
class SearchProductsListenerTest extends TestCase
|
||||
class SearchProductsListenerTest extends ModuleTestCase
|
||||
{
|
||||
private SearchProductsListener $listener;
|
||||
|
||||
|
||||
+2
-2
@@ -9,14 +9,14 @@ use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* SeoCategoryCacheListener 테스트
|
||||
*
|
||||
* 카테고리 변경 시 SEO 캐시 무효화 리스너의 동작을 검증합니다.
|
||||
*/
|
||||
class SeoCategoryCacheListenerTest extends TestCase
|
||||
class SeoCategoryCacheListenerTest extends ModuleTestCase
|
||||
{
|
||||
protected SeoCategoryCacheListener $listener;
|
||||
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* SeoProductCacheListener 테스트
|
||||
@@ -21,7 +21,7 @@ use Tests\TestCase;
|
||||
* - 생성/수정 시 단건 캐시 재생성
|
||||
* - 삭제 시 재생성 없이 무효화만
|
||||
*/
|
||||
class SeoProductCacheListenerTest extends TestCase
|
||||
class SeoProductCacheListenerTest extends ModuleTestCase
|
||||
{
|
||||
protected SeoProductCacheListener $listener;
|
||||
|
||||
|
||||
+2
-2
@@ -7,14 +7,14 @@ use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoSettingsCacheListener;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 이커머스 SEO 설정 리스너 테스트
|
||||
*
|
||||
* 이커머스 모듈 설정 변경 시 SEO 관련 캐시 선별 무효화를 검증합니다.
|
||||
*/
|
||||
class SeoSettingsCacheListenerTest extends TestCase
|
||||
class SeoSettingsCacheListenerTest extends ModuleTestCase
|
||||
{
|
||||
private SeoSettingsCacheListener $listener;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Requests;
|
||||
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreBanksRequest;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 은행 목록 저장 요청 검증 테스트
|
||||
@@ -15,7 +15,7 @@ use Tests\TestCase;
|
||||
* - 현재 로케일 은행명만 필수
|
||||
* - 빈 배열 허용
|
||||
*/
|
||||
class StoreBanksRequestTest extends TestCase
|
||||
class StoreBanksRequestTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 은행명 검증은 "현재 앱 로케일" 필드만 필수로 본다. 테스트 데이터가 ko/en 을 제공하므로
|
||||
@@ -30,12 +30,11 @@ class StoreBanksRequestTest extends TestCase
|
||||
/**
|
||||
* 검증 수행
|
||||
*
|
||||
* @param array $data 검증 대상 데이터
|
||||
* @return \Illuminate\Validation\Validator
|
||||
* @param array $data 검증 대상 데이터
|
||||
*/
|
||||
protected function validate(array $data): \Illuminate\Validation\Validator
|
||||
{
|
||||
$request = new StoreBanksRequest();
|
||||
$request = new StoreBanksRequest;
|
||||
|
||||
return Validator::make($data, $request->rules());
|
||||
}
|
||||
|
||||
+2
-2
@@ -3,14 +3,14 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Requests;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreEcommerceSettingsRequest;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* StoreEcommerceSettingsRequest SEO 필드 검증 테스트
|
||||
*
|
||||
* 코어로 이관된 SEO 필드가 제거되었는지, 이커머스 고유 SEO 필드가 유지되는지 검증합니다.
|
||||
*/
|
||||
class StoreEcommerceSettingsRequestSeoTest extends TestCase
|
||||
class StoreEcommerceSettingsRequestSeoTest extends ModuleTestCase
|
||||
{
|
||||
private StoreEcommerceSettingsRequest $request;
|
||||
|
||||
|
||||
+14
-14
@@ -2,8 +2,9 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Resources;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* HasMultiCurrencyPrices Trait 단위 테스트
|
||||
@@ -12,13 +13,12 @@ use PHPUnit\Framework\TestCase;
|
||||
* - applyRounding 결과의 IEEE 754 오차가 decimal_places로 제거되는지 확인
|
||||
* - 예: 4505 * 0.01 = 45.050000000000004 → round(_, 2) → 45.05
|
||||
*/
|
||||
class HasMultiCurrencyPricesTest extends TestCase
|
||||
class HasMultiCurrencyPricesTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* Trait 메서드를 테스트할 수 있는 익명 클래스 인스턴스 생성
|
||||
*
|
||||
* @param array $currencies 통화 설정
|
||||
* @return object
|
||||
*/
|
||||
protected function createTraitInstance(array $currencies = []): object
|
||||
{
|
||||
@@ -74,7 +74,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
|
||||
return new class($currencySettings)
|
||||
{
|
||||
use \Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
use HasMultiCurrencyPrices;
|
||||
|
||||
private array $testCurrencies;
|
||||
|
||||
@@ -104,12 +104,12 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
/**
|
||||
* 테스트용 공개 래퍼
|
||||
*/
|
||||
public function testBuildMultiCurrencyPrices(float|int $basePrice): array
|
||||
public function exposeBuildMultiCurrencyPrices(float|int $basePrice): array
|
||||
{
|
||||
return $this->buildMultiCurrencyPrices($basePrice);
|
||||
}
|
||||
|
||||
public function testApplyRounding(float $price, string $unit, string $method): float
|
||||
public function exposeApplyRounding(float $price, string $unit, string $method): float
|
||||
{
|
||||
return $this->applyRounding($price, $unit, $method);
|
||||
}
|
||||
@@ -123,7 +123,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
public function price_값에_부동소수점_오차가_없어야_한다(): void
|
||||
{
|
||||
$instance = $this->createTraitInstance();
|
||||
$result = $instance->testBuildMultiCurrencyPrices(53000);
|
||||
$result = $instance->exposeBuildMultiCurrencyPrices(53000);
|
||||
|
||||
// USD: (53000/1000) * 0.85 = 45.05 (45.050000000000004가 아님)
|
||||
$this->assertSame(45.05, $result['USD']['price']);
|
||||
@@ -145,7 +145,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
public function 기본통화는_원래_가격을_반환한다(): void
|
||||
{
|
||||
$instance = $this->createTraitInstance();
|
||||
$result = $instance->testBuildMultiCurrencyPrices(53000);
|
||||
$result = $instance->exposeBuildMultiCurrencyPrices(53000);
|
||||
|
||||
$this->assertTrue($result['KRW']['is_default']);
|
||||
$this->assertSame(53000, $result['KRW']['price']);
|
||||
@@ -158,7 +158,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
public function decimal_places_0인_통화는_정수를_반환한다(): void
|
||||
{
|
||||
$instance = $this->createTraitInstance();
|
||||
$result = $instance->testBuildMultiCurrencyPrices(53500);
|
||||
$result = $instance->exposeBuildMultiCurrencyPrices(53500);
|
||||
|
||||
// JPY: (53500/1000) * 115 = 6152.5 → floor → 6152
|
||||
$jpyPrice = $result['JPY']['price'];
|
||||
@@ -175,7 +175,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
$instance = $this->createTraitInstance();
|
||||
|
||||
// 51500원 → EUR: (51500/1000) * 0.78 = 40.17
|
||||
$result = $instance->testBuildMultiCurrencyPrices(51500);
|
||||
$result = $instance->exposeBuildMultiCurrencyPrices(51500);
|
||||
$this->assertSame(40.17, $result['EUR']['price']);
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
$instance = $this->createTraitInstance();
|
||||
|
||||
// 51000원 → CNY: (51000/1000) * 5.8 = 295.8
|
||||
$result = $instance->testBuildMultiCurrencyPrices(51000);
|
||||
$result = $instance->exposeBuildMultiCurrencyPrices(51000);
|
||||
$this->assertSame(295.8, $result['CNY']['price']);
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
$testPrices = [10000, 25000, 53000, 57000, 99000, 150000];
|
||||
|
||||
foreach ($testPrices as $price) {
|
||||
$result = $instance->testBuildMultiCurrencyPrices($price);
|
||||
$result = $instance->exposeBuildMultiCurrencyPrices($price);
|
||||
|
||||
foreach (['USD', 'JPY', 'CNY', 'EUR'] as $code) {
|
||||
$priceValue = $result[$code]['price'];
|
||||
@@ -226,12 +226,12 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
* applyRounding 내부 함수의 부동소수점 문제 직접 검증
|
||||
*/
|
||||
#[Test]
|
||||
public function applyRounding_결과에_부동소수점_오차_확인(): void
|
||||
public function apply_rounding_결과에_부동소수점_오차_확인(): void
|
||||
{
|
||||
$instance = $this->createTraitInstance();
|
||||
|
||||
// 4505 * 0.01 → 이전에는 45.050000000000004 발생
|
||||
$rounded = $instance->testApplyRounding(45.05, '0.01', 'round');
|
||||
$rounded = $instance->exposeApplyRounding(45.05, '0.01', 'round');
|
||||
// applyRounding 자체는 여전히 부동소수점 오차가 있을 수 있지만
|
||||
// buildMultiCurrencyPrices에서 round()로 제거됨
|
||||
$this->assertEqualsWithDelta(45.05, $rounded, 0.0001);
|
||||
|
||||
@@ -220,6 +220,11 @@ class CashReceiptServiceTest extends ModuleTestCase
|
||||
$this->assertSame('receipt-1', $receipt->raw_response['receiptKey']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 금액 변동(감소) 시 전체취소 후 잔액으로 재발급하는지 확인
|
||||
*
|
||||
* @effects amount_change_triggers_cancel_then_reissue
|
||||
*/
|
||||
#[Test]
|
||||
public function sync_금액감소시_전체취소_후_전액_재발급한다(): void
|
||||
{
|
||||
|
||||
+7
-20
@@ -6,32 +6,30 @@ use App\Contracts\Extension\StorageInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Models\CategoryImage;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryImageRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CategoryImageService;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* CategoryImageService 단위 테스트
|
||||
*
|
||||
* StorageInterface 기반 이미지 업로드, 삭제 등을 테스트합니다.
|
||||
*/
|
||||
class CategoryImageServiceTest extends TestCase
|
||||
class CategoryImageServiceTest extends ModuleTestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private CategoryImageService $service;
|
||||
|
||||
/** @var \Mockery\MockInterface&CategoryImageRepositoryInterface */
|
||||
/** @var MockInterface&CategoryImageRepositoryInterface */
|
||||
private $repository;
|
||||
|
||||
/** @var \Mockery\MockInterface&StorageInterface */
|
||||
/** @var MockInterface&StorageInterface */
|
||||
private $storage;
|
||||
|
||||
private User $user;
|
||||
@@ -63,7 +61,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_upload_stores_image_and_creates_record(): void
|
||||
{
|
||||
@@ -127,7 +124,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertEquals(600, $result->height);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_upload_with_temp_key_creates_temp_image(): void
|
||||
{
|
||||
@@ -179,7 +175,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertEquals($tempKey, $result->temp_key);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_upload_with_alt_text(): void
|
||||
{
|
||||
@@ -213,7 +208,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertEquals($altText, $result->alt_text);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_upload_fires_hooks(): void
|
||||
{
|
||||
@@ -259,7 +253,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
HookManager::clearAction('sirsoft-ecommerce.category-image.after_upload');
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_delete_removes_file_from_storage(): void
|
||||
{
|
||||
@@ -310,7 +303,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_delete_fires_hooks(): void
|
||||
{
|
||||
@@ -347,9 +339,8 @@ class CategoryImageServiceTest extends TestCase
|
||||
HookManager::clearAction('sirsoft-ecommerce.category-image.after_delete');
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_linkTempImages_links_temp_files_to_category(): void
|
||||
public function test_link_temp_images_links_temp_files_to_category(): void
|
||||
{
|
||||
// Arrange
|
||||
$tempKey = 'temp-uuid-456';
|
||||
@@ -368,7 +359,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertEquals(3, $result);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_reorder_updates_image_order(): void
|
||||
{
|
||||
@@ -392,7 +382,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_reorder_fires_hooks(): void
|
||||
{
|
||||
@@ -521,7 +510,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_update_modifies_image_metadata(): void
|
||||
{
|
||||
@@ -549,7 +537,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertEquals($altText, $result->alt_text);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_update_fires_hooks(): void
|
||||
{
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use ReflectionClass;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 결제수단별 마일리지 차감 시점 설정 테스트 (마일리지/MP06)
|
||||
@@ -14,7 +14,7 @@ use Tests\TestCase;
|
||||
* EcommerceSettingsService::getMileageDeductionTiming(string) 의 결제수단별 반환과
|
||||
* 미설정 기본값(무통장=order_placed / 그 외=payment_complete)을 검증한다.
|
||||
*/
|
||||
class EcommerceSettingsMileageTimingTest extends TestCase
|
||||
class EcommerceSettingsMileageTimingTest extends ModuleTestCase
|
||||
{
|
||||
private EcommerceSettingsService $service;
|
||||
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 통화 환율 영속성 테스트 (U11-A)
|
||||
@@ -13,7 +13,7 @@ use Tests\TestCase;
|
||||
* 관리자가 일부 통화를 빼고 저장하면 defaults 통화가 영구 소실되던 영속성 공백을 검증한다.
|
||||
* getAllSettings() 의 code 기준 병합(mergeCurrenciesByCode)으로 누락 통화 보충(환율 저장본 우선).
|
||||
*/
|
||||
class EcommerceSettingsServiceCurrencyMergeTest extends TestCase
|
||||
class EcommerceSettingsServiceCurrencyMergeTest extends ModuleTestCase
|
||||
{
|
||||
private EcommerceSettingsService $service;
|
||||
|
||||
|
||||
+67
@@ -772,6 +772,73 @@ class OrderCancellationServiceTest extends ModuleTestCase
|
||||
$this->assertEquals(RefundStatusEnum::COMPLETED, $result->orderRefund->refund_status);
|
||||
}
|
||||
|
||||
/**
|
||||
* B-3-1-a: PG 환불 훅이 OrderRefund 레코드를 6번째 인자로 전달하는지 검증
|
||||
*
|
||||
* PG 리스너는 이 레코드의 id 로 멱등키(Idempotency-Key)를 조립한다. 네트워크 재시도로
|
||||
* 같은 취소가 두 번 도달해도 PG 가 중복 청구를 차단하려면 환불 시도마다 고유한 값이 필요하다.
|
||||
*
|
||||
* @effects refund_hook_passes_order_refund_record
|
||||
*/
|
||||
public function test_payment_refund_hook_passes_order_refund_record(): void
|
||||
{
|
||||
$received = null;
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.payment.refund',
|
||||
function ($default, $order, $payment, $refundAmount, $reason, $refund = null) use (&$received) {
|
||||
$received = $refund;
|
||||
|
||||
return ['success' => true, 'transaction_id' => 'TXN', 'error_code' => null, 'error_message' => null];
|
||||
}
|
||||
);
|
||||
|
||||
$this->createShippingPolicy();
|
||||
[$pA, $oA] = $this->createProductWithOption(price: 20000);
|
||||
|
||||
$input = new CalculationInput(
|
||||
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
|
||||
);
|
||||
$order = $this->createOrderFromCalculation($input);
|
||||
|
||||
$result = $this->cancellationService->cancelOrder(order: $order, cancelPg: true);
|
||||
|
||||
$this->assertInstanceOf(OrderRefund::class, $received, 'PG 환불 훅은 OrderRefund 레코드를 전달해야 합니다');
|
||||
$this->assertSame($result->orderRefund->id, $received->id, '전달된 환불 레코드는 이번 환불 시도의 것이어야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* B-3-1-b: 인자를 5개만 선언한 기존(레거시) 리스너가 계속 동작하는지 검증
|
||||
*
|
||||
* OrderRefund 인자 추가는 하위호환이어야 한다 — 초과 인자는 무시되고 예외가 나지 않는다.
|
||||
*
|
||||
* @effects refund_hook_backward_compatible_with_five_arg_listener
|
||||
*/
|
||||
public function test_payment_refund_hook_is_backward_compatible_with_five_arg_listener(): void
|
||||
{
|
||||
$hookCalled = false;
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.payment.refund',
|
||||
function ($default, $order, $payment, $refundAmount, $reason) use (&$hookCalled) {
|
||||
$hookCalled = true;
|
||||
|
||||
return ['success' => true, 'transaction_id' => 'TXN', 'error_code' => null, 'error_message' => null];
|
||||
}
|
||||
);
|
||||
|
||||
$this->createShippingPolicy();
|
||||
[$pA, $oA] = $this->createProductWithOption(price: 20000);
|
||||
|
||||
$input = new CalculationInput(
|
||||
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
|
||||
);
|
||||
$order = $this->createOrderFromCalculation($input);
|
||||
|
||||
$result = $this->cancellationService->cancelOrder(order: $order, cancelPg: true);
|
||||
|
||||
$this->assertTrue($hookCalled, '5-파라미터 리스너도 호출되어야 합니다');
|
||||
$this->assertEquals(RefundStatusEnum::COMPLETED, $result->orderRefund->refund_status);
|
||||
}
|
||||
|
||||
/**
|
||||
* B-3-2: PG 환불 실패 시 전체 트랜잭션이 롤백되는지 검증
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Contracts\Extension\StorageInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
@@ -18,18 +17,16 @@ use Modules\Sirsoft\Ecommerce\Models\ProductImage;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductImageRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductImageService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ProductImageService 단위 테스트
|
||||
*
|
||||
* StorageInterface 기반 상품 이미지 업로드, 삭제, 순서변경, 임시연결 등을 테스트합니다.
|
||||
*/
|
||||
class ProductImageServiceTest extends TestCase
|
||||
class ProductImageServiceTest extends ModuleTestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private ProductImageService $service;
|
||||
|
||||
/** @var MockInterface&ProductImageRepositoryInterface */
|
||||
|
||||
@@ -5,25 +5,23 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\UnauthorizedPresetAccessException;
|
||||
use Modules\Sirsoft\Ecommerce\Models\SearchPreset;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\SearchPresetRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\SearchPresetService;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* SearchPresetService 단위 테스트
|
||||
*/
|
||||
class SearchPresetServiceTest extends TestCase
|
||||
class SearchPresetServiceTest extends ModuleTestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private SearchPresetService $service;
|
||||
|
||||
/** @var \Mockery\MockInterface&SearchPresetRepositoryInterface */
|
||||
/** @var MockInterface&SearchPresetRepositoryInterface */
|
||||
private $repository;
|
||||
|
||||
private User $user;
|
||||
@@ -54,9 +52,8 @@ class SearchPresetServiceTest extends TestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_getPresets_returns_user_presets(): void
|
||||
public function test_get_presets_returns_user_presets(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->actingAs($this->user);
|
||||
@@ -80,7 +77,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
$this->assertCount(2, $result);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_create_creates_preset_with_correct_data(): void
|
||||
{
|
||||
@@ -116,7 +112,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
$this->assertEquals($name, $result->preset_name);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_create_fires_hooks(): void
|
||||
{
|
||||
@@ -167,7 +162,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
HookManager::clearAction('sirsoft-ecommerce.preset.after_create');
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_update_updates_preset(): void
|
||||
{
|
||||
@@ -203,7 +197,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
$this->assertEquals('New Name', $result->preset_name);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_update_fires_hooks(): void
|
||||
{
|
||||
@@ -254,7 +247,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
HookManager::clearAction('sirsoft-ecommerce.preset.after_update');
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_update_throws_exception_for_other_user_preset(): void
|
||||
{
|
||||
@@ -277,7 +269,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
$this->service->update($preset, ['name' => 'Hacked']);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_delete_deletes_preset(): void
|
||||
{
|
||||
@@ -305,7 +296,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_delete_fires_hooks(): void
|
||||
{
|
||||
@@ -347,7 +337,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
HookManager::clearAction('sirsoft-ecommerce.preset.after_delete');
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_delete_throws_exception_for_other_user_preset(): void
|
||||
{
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\SequenceAlgorithm;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\SequenceType;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\SequenceCodeDuplicateException;
|
||||
@@ -13,20 +13,18 @@ use Modules\Sirsoft\Ecommerce\Exceptions\SequenceOverflowException;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Sequence;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\SequenceRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\SequenceService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SequenceService 단위 테스트
|
||||
*/
|
||||
class SequenceServiceTest extends TestCase
|
||||
class SequenceServiceTest extends ModuleTestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private SequenceService $service;
|
||||
|
||||
/** @var \Mockery\MockInterface&SequenceRepositoryInterface */
|
||||
/** @var MockInterface&SequenceRepositoryInterface */
|
||||
private $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
|
||||
@@ -7,16 +7,16 @@ use App\Extension\Helpers\SettingsMigrator;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Services\ModuleSettingsService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Upgrades\Upgrade_0_7_0;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* v0.7.0 업그레이드 스텝 테스트
|
||||
*
|
||||
* SEO 설정 코어 이관에 따른 User Agent 마이그레이션 및 비이커머스 설정 제거를 검증합니다.
|
||||
*/
|
||||
class Upgrade_0_7_0_Test extends TestCase
|
||||
class Upgrade_0_7_0_Test extends ModuleTestCase
|
||||
{
|
||||
private Upgrade_0_7_0 $upgradeStep;
|
||||
|
||||
|
||||
@@ -4,10 +4,25 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-07-22
|
||||
## [1.1.0] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- 현금영수증을 발급사로 등록할 수 있습니다. 결제는 다른 PG로 받으면서 현금영수증만 KG이니시스로 발급하는 구성이 가능합니다.
|
||||
- 현금영수증 발행취소를 지원합니다. 부분환불 등으로 주문 금액이 바뀌면 기존 영수증을 취소하고 새 금액으로 다시 발급합니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 현금영수증 발행이 관리자 주문상세의 공용 현금영수증 카드로 통합되었습니다. 발급 이력, 재발급, 발급 실패 안내를 한 곳에서 확인할 수 있습니다.
|
||||
- 이커머스 모듈 최소 버전을 1.1.0으로 상향했습니다.
|
||||
|
||||
### Removed
|
||||
|
||||
- KG이니시스 전용 현금영수증 발행 화면과 API를 제거했습니다. 공용 현금영수증 기능으로 대체됩니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 일본 편의점결제(CVS) 관련 안내 메시지가 번역되지 않고 내부 코드 그대로 표시되던 문제를 수정했습니다. 주문을 찾을 수 없거나 입금 통보 테스트·입금기한 만료 처리를 할 때 정상적인 한국어(영어) 안내가 표시됩니다.
|
||||
- 이 플러그인이 설치된 사이트에서 관리자 주문 목록의 '일부취소' 표시와 적립예정 포인트·마일리지 사용 금액, 주문상품의 추가옵션 요약이 보이지 않던 문제를 수정했습니다. 결제 정보 칸에 테스트결제 표시를 덧붙이는 과정에서 목록 표 구성 전체를 옛 버전으로 되돌려 놓고 있었습니다.
|
||||
- 관리자 결제수단 설정에서 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제를 수정했습니다. 해당 결제수단의 PG사 목록이 항상 비어 있어 기본 PG사만 사용할 수 있었고, 결제수단별로 다른 PG사를 지정하는 설정이 화면에서 불가능했습니다.
|
||||
- 카드결제를 거치지 않은 주문(무통장입금 등)의 완료 화면에서도 영수증 조회를 시도해 불필요한 요청이 나가던 문제를 수정했습니다. 화면에는 영향이 없었으나 이제 카드결제 주문에서만 조회합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
|
||||
@@ -15,7 +15,8 @@ PC 결제는 KG 이니시스 `INIStdPay.js` 표준결제창을 사용하고, 모
|
||||
- 결제 취소 및 부분취소 연동
|
||||
- PG 측 결제 취소 확인 시점의 활동 로그 별도 기록 (PG 응답 시각·취소 TID 사후 추적)
|
||||
- 주문 완료/마이페이지 영수증 버튼 주입
|
||||
- 관리자 주문 상세의 거래 조회, 현금영수증 발행, 에스크로 처리 UI 확장
|
||||
- 관리자 주문 상세의 거래 조회, 에스크로 처리 UI 확장
|
||||
- 현금영수증 발급/취소 (이커머스 모듈의 공용 현금영수증 프로바이더로 등록 — PG 결제사와 독립 선택 가능)
|
||||
- 일본 결제 CBT(JPPG) 인증/승인, 테스트 상품 생성, 연결 진단
|
||||
- 승인 URL 화이트리스트, 콜백 재처리 방지, 타임스탬프 신선도 검증
|
||||
|
||||
@@ -213,7 +214,6 @@ CBT 승인 이후 로컬 후속 처리에 실패하면 CBT 전용 취소 API로
|
||||
| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/test-mode-map` | 주문목록 테스트 모드 배지용 맵 조회 |
|
||||
| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/transaction/query` | TID로 KG 이니시스 거래 조회 |
|
||||
| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/transaction-status` | 주문번호로 거래 상태 조회 |
|
||||
| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cash-receipt` | 현금영수증 별도 발행 |
|
||||
| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-delivery` | 에스크로 배송 등록 폼 데이터 조회 |
|
||||
| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-delivery` | KG 이니시스 에스크로 배송 등록 |
|
||||
| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-deny-confirm` | 에스크로 구매거절확인 |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "plugins/sirsoft-pay_kginicis",
|
||||
"description": "KG Inicis PG Plugin for G7 platform",
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"type": "library",
|
||||
"authors": [
|
||||
{
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
|
||||
|
||||
<!-- @generated:start:api-readme-index -->
|
||||
- **문서 수**: 5 · **엔드포인트 수**: 22
|
||||
- **문서 수**: 5 · **엔드포인트 수**: 34
|
||||
|
||||
| 문서 | 도메인 | 엔드포인트 |
|
||||
| --- | --- | --- |
|
||||
| [cbt.md](cbt.md) | `cbt` | 2 |
|
||||
| [orders.md](orders.md) | `orders` | 13 |
|
||||
| [payment.md](payment.md) | `payment` | 5 |
|
||||
| [orders.md](orders.md) | `orders` | 12 |
|
||||
| [payment.md](payment.md) | `payment` | 18 |
|
||||
| [transaction.md](transaction.md) | `transaction` | 1 |
|
||||
| [vbank.md](vbank.md) | `vbank` | 1 |
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Cbt 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
|
||||
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
@@ -42,10 +42,10 @@ _단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| egress_ip | string | `118.235.10.131` | 서버가 외부 통신 시 사용하는 egress IP. KG 이니시스 측에 DEVCBT 접근용 IP 화이트리스트 등록을 요청할 때 알려줄 IP이며, 외부 echo 서비스(ipify 등)를 순차 조회해 얻는다(모두 실패 시 null). |
|
||||
| egress_ip | string | `210.90.128.2` | 서버가 외부 통신 시 사용하는 egress IP. KG 이니시스 측에 DEVCBT 접근용 IP 화이트리스트 등록을 요청할 때 알려줄 IP이며, 외부 echo 서비스(ipify 등)를 순차 조회해 얻는다(모두 실패 시 null). |
|
||||
| server_ip | string | `127.0.0.1` | `$_SERVER['SERVER_ADDR']` 로 읽은 서버 내부 IP. egress IP와 대조해 NAT/프록시 여부를 가늠하는 참고값이다. |
|
||||
| hosts | array | `[{"name":"devcbt.inicis.com","env":"test","dns_resolved_i…` | 진단 대상 호스트별 결과 배열. 각 항목은 호스트명(`devcbt.inicis.com`), 환경(`test`), DNS 해석 IP(`dns_resolved_ip`), TCP 443 도달 여부(`tcp_443_reachable`)와 에러·응답지연(`tcp_443_error`, `tcp_443_latency_ms`)을 담는다. 운영계(`cbt.inicis.com`)는 화이트리스트 제약이 없어 제외된다. |
|
||||
| callback | object | `{"app_url":"https:\/\/test.example.com","callback_url":"h…` | 결제 콜백 URL 진단 정보. 앱 URL·콜백 URL과 각각의 HTTPS 여부(`app_url_https`, `callback_url_https`)·공인 호스트 여부(`app_url_public`, `callback_url_public`), 그리고 콜백 호스트가 앱 URL 호스트와 일치하는지(`host_matches_app_url`)를 담아 CBT 콜백 수신 가능 여부를 점검한다. |
|
||||
| callback | object | `{"app_url":"https:\/\/g7.dev","callback_url":"https:\/\/g…` | 결제 콜백 URL 진단 정보. 앱 URL·콜백 URL과 각각의 HTTPS 여부(`app_url_https`, `callback_url_https`)·공인 호스트 여부(`app_url_public`, `callback_url_public`), 그리고 콜백 호스트가 앱 URL 호스트와 일치하는지(`host_matches_app_url`)를 담아 CBT 콜백 수신 가능 여부를 점검한다. |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -122,10 +122,10 @@ _단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| product_id | integer | `4` | product 식별자 (연관 리소스 참조) |
|
||||
| product_code | string | `CBT-TEST-20260708063238` | 생성된 테스트 상품의 상품코드. `CBT-TEST-` 접두사에 생성 시각(YmdHis)을 붙여 자동 부여되며, SKU는 여기에 `KGINICIS-` 접두사를 더해 만들어진다. |
|
||||
| admin_url | string | `/admin/ecommerce/products/4/edit` | admin URL |
|
||||
| shop_url | string | `/shop/products/4?locale=ja` | shop URL |
|
||||
| product_id | integer | `114` | product 식별자 (연관 리소스 참조) |
|
||||
| product_code | string | `CBT-TEST-20260712085816` | 생성된 테스트 상품의 상품코드. `CBT-TEST-` 접두사에 생성 시각(YmdHis)을 붙여 자동 부여되며, SKU는 여기에 `KGINICIS-` 접두사를 더해 만들어진다. |
|
||||
| admin_url | string | `/admin/ecommerce/products/114/edit` | admin URL |
|
||||
| shop_url | string | `/shop/products/114?locale=ja` | shop URL |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Orders 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
|
||||
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
@@ -75,49 +75,6 @@ HTTP/1.1 200
|
||||
`AdminOrderListController@testModeMap` 가 최근 6개월 이내 `pg_provider = kginicis` 결제 주문을 조회해 그중 테스트 모드 결제만 `{ "주문번호": true, ... }` 맵으로 반환한다. 테스트 판별은 `payment_meta.is_test_mode === true` → `pg_raw_response.mid` 가 KG 이니시스 Live MID 접두사(`SIR`)가 아님 → `transaction_id` 에 `Test` 포함 순으로 이루어진다. 어드민 주문 목록에서 결제수단 셀 하단에 "(테스트 결제)" 배지를 붙이는 용도이며, 응답 필드 키가 곧 주문번호이므로 특정 주문이 맵에 존재하면 테스트 결제로 간주하면 된다. `sirsoft-ecommerce.orders.read` 권한이 필요하고, 파라미터 없이 전체 맵을 한 번에 조회한다.
|
||||
|
||||
|
||||
### POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cash-receipt
|
||||
<!-- @generated:start:api.plugins.sirsoft-pay_kginicis.admin.orders.cash-receipt.issue -->
|
||||
- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.cash-receipt.issue`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminCashReceiptController@issue`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| orderNumber | path | string | 예 | — | 대상 order number의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cash-receipt HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
`AdminCashReceiptController@issue` 가 이미 승인 완료된 KG 이니시스 결제 건에 대해 현금영수증을 별도 발행한다. 요청의 `issue_type`(`0`=소득공제, `1`=지출증빙)과 `issue_number`(휴대폰/사업자번호 등 식별번호)를 검증한 뒤, `paid_amount_local` 기준 금액과 부가세(저장값 우선, 없으면 총액의 10/110)를 계산해 `KgInicisApiService::issueCashReceipt` 로 발행 요청을 보낸다. 발행 성공 시 `is_cash_receipt_issued`, `cash_receipt_type`, 마스킹된 식별번호(`cash_receipt_identifier`, 끝 4자리만 노출)를 저장하며, 식별번호 원문은 저장하지 않는다. `sirsoft-ecommerce.orders.update` 권한이 필요하고, 검증 실패 422 / 주문 미존재 404 / 이미 발행됨 409 / PG 발행 실패(resultCode≠`00`) 502 / 예외 500 으로 상태코드가 매핑된다.
|
||||
|
||||
|
||||
### GET /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cbt-cvs
|
||||
<!-- @generated:start:api.plugins.sirsoft-pay_kginicis.admin.orders.cbt-cvs.show -->
|
||||
- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.cbt-cvs.show`
|
||||
@@ -133,7 +90,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cbt-cvs HTTP/1.1
|
||||
GET /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cbt-cvs HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
@@ -246,7 +203,7 @@ HTTP/1.1 200
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cbt-cvs/expire HTTP/1.1
|
||||
POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cbt-cvs/expire HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
@@ -254,11 +211,11 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -289,7 +246,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cbt-cvs/recheck HTTP/1.1
|
||||
POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cbt-cvs/recheck HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
@@ -402,7 +359,7 @@ HTTP/1.1 200
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cbt-cvs/simulate-notify HTTP/1.1
|
||||
POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cbt-cvs/simulate-notify HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
@@ -410,11 +367,11 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -445,7 +402,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cbt-reconciliation HTTP/1.1
|
||||
GET /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cbt-reconciliation HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
@@ -453,9 +410,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
|
||||
|
||||
<!-- 실측 응답에 필드 없음(빈 목록 등) — 데이터가 있는 상태로 재실측하거나 사람이 작성. -->
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -500,7 +455,7 @@ HTTP/1.1 200
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cbt-reconciliation/refund-retry HTTP/1.1
|
||||
POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cbt-reconciliation/refund-retry HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
@@ -508,11 +463,11 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -543,7 +498,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/escrow-delivery HTTP/1.1
|
||||
GET /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-delivery HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
@@ -636,7 +591,7 @@ HTTP/1.1 200
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/escrow-delivery HTTP/1.1
|
||||
POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-delivery HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
@@ -644,11 +599,11 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -679,7 +634,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/escrow-deny-confirm HTTP/1.1
|
||||
POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-deny-confirm HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
@@ -687,11 +642,11 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-502 — 응답 예시는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -722,7 +677,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/transaction-status HTTP/1.1
|
||||
GET /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/transaction-status HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
@@ -881,7 +836,7 @@ HTTP/1.1 200
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/plugins/sirsoft-pay_kginicis/user/orders/APIDOC-KGINICIS-000001/receipt HTTP/1.1
|
||||
GET /api/plugins/sirsoft-pay_kginicis/user/orders/{orderNumber}/receipt HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
@@ -889,9 +844,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
|
||||
|
||||
<!-- 실측 응답에 필드 없음(빈 목록 등) — 데이터가 있는 상태로 재실측하거나 사람이 작성. -->
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Payment 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
|
||||
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
@@ -37,7 +37,7 @@ Accept: application/json
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -45,7 +45,12 @@ Accept: application/json
|
||||
|
||||
**에러 응답**
|
||||
|
||||
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 403 | Forbidden | 요청의 구매자 정보(`buyer_email` / `buyer_phone`)가 주문의 구매자와 일치하지 않는 경우 |
|
||||
| 404 | Not Found | `oid` 에 해당하는 주문이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | `oid` / `price` 누락, 일본 CBT 결제 비활성화·미설정, 주문이 결제 가능 상태가 아님, 주문 통화가 JPY 가 아님, 금액이 주문 청구액과 불일치 |
|
||||
| 429 | Too Many Requests | 동일 IP·`oid` 조합에서 분당 10회를 초과해 요청한 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
@@ -74,7 +79,7 @@ Accept: application/json
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -82,7 +87,12 @@ Accept: application/json
|
||||
|
||||
**에러 응답**
|
||||
|
||||
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 403 | Forbidden | 구매자 정보가 주문과 불일치하거나, checkout-token 검증에 실패한 경우(토큰 위조·컨텍스트 불일치) |
|
||||
| 404 | Not Found | `oid` 에 해당하는 주문이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | `oid` / `price` / `timestamp` 누락, 타임스탬프 만료·형식 오류(재생 공격 방지), 일본 CBT 결제 비활성화·미설정, 주문이 결제 가능 상태가 아님, 주문 통화가 JPY 가 아님, 금액이 주문 청구액과 불일치 |
|
||||
| 429 | Too Many Requests | 동일 IP·`oid` 조합에서 분당 10회를 초과해 요청한 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
@@ -128,7 +138,7 @@ Content-Type: application/json
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: http-404 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -182,7 +192,7 @@ Content-Type: application/json
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -236,7 +246,7 @@ Content-Type: application/json
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -255,3 +265,760 @@ Content-Type: application/json
|
||||
PC 표준결제창(KRW)이 요구하는 서명(signature)·검증값(verification)·mKey 를 생성해 반환하는, PC 결제 흐름의 시작점입니다. `KgInicisApiService` 의 `generateSignature()`·`generateVerification()`·`getMKey()` 를 호출해 주문번호·금액·타임스탬프로 만든 서명 세트를 내려주며, 프론트엔드는 이 값으로 KG 이니시스 표준결제창을 호출합니다. 인증은 불필요하나(결제창에서 직접 호출) FormRequest 검증, 재생 공격 방지용 타임스탬프 신선도 확인, `oid` 기준 IP별 분당 20회 레이트리밋, 주문 존재·결제 가능 상태·통화 KRW·구매자 일치·금액 일치 검증과 표준결제 자격증명 설정 여부를 확인합니다. 파라미터 검증 실패·타임스탬프 만료·통화/금액 불일치·자격증명 미설정은 422, 레이트리밋 초과 429, 주문 미존재 404, 구매자 검증 실패 403 으로 응답합니다.
|
||||
|
||||
|
||||
### POST /plugins/sirsoft-pay_kginicis/payment/callback
|
||||
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.callback -->
|
||||
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.callback`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\PaymentCallbackController@authCallback`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| resultCode | body | string | 예 | — | 결제 인증 결과 코드 (`0000` = 성공). `2001`/`0021`/`0022`/빈값은 사용자 취소로 간주해 에러 없이 체크아웃으로 복귀한다. |
|
||||
| resultMsg | body | string | 아니오 | — | 결제 인증 결과 메시지. 실패 시 실패 URL 의 `message` 파라미터로 전달되며 '취소'/'사용자' 포함 여부로 사용자 취소를 판별한다. |
|
||||
| authToken | body | string | 아니오 | — | 결제 인증 토큰. 서버 승인 API(`authUrl`) 호출과 승인 취소(net cancel) 호출에 사용된다. |
|
||||
| authUrl | body | string | 아니오 | — | 서버 승인 API 요청 URL. `idc_name` 과 함께 화이트리스트 검증(SSRF 방어)을 통과해야 호출된다. |
|
||||
| checkAckUrl | body | string | 아니오 | — | `authUrl` 과 동일 역할의 승인 URL (KG 이니시스 버전에 따라 이 키로 전달됨). `authUrl` 미제공 시 대체로 사용한다. |
|
||||
| netCancelUrl | body | string | 아니오 | — | 망취소(net cancel) 요청 URL. 실제 호출에는 `idc_name` 으로 재해석한 서버 화이트리스트 URL 을 사용한다. |
|
||||
| idc_name | body | string | 아니오 | — | KG 이니시스 IDC 센터 코드(fc/ks/stg). 승인/망취소 URL 화이트리스트 검증의 기준값. |
|
||||
| MOID | body | string | 아니오 | — | 주문번호 (구버전 필드명). 주문 조회 키로 사용되며 `orderNumber` 보다 우선한다. |
|
||||
| orderNumber | body | string | 아니오 | — | 주문번호 (신버전 필드명). `MOID` 가 없을 때 주문 조회 키로 사용된다. |
|
||||
| TotPrice | body | integer | 아니오 | min 0 | 결제 총액. 콜백에 없으면 서버 승인 응답의 `TotPrice` 로 보완해 결제 완료 처리에 사용한다. |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /plugins/sirsoft-pay_kginicis/payment/callback HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"resultCode": "예시값",
|
||||
"resultMsg": "예시값",
|
||||
"authToken": "{YOUR_TOKEN}",
|
||||
"authUrl": "https://example.com",
|
||||
"checkAckUrl": "https://example.com",
|
||||
"netCancelUrl": "https://example.com",
|
||||
"idc_name": "예시 이름",
|
||||
"MOID": "예시값",
|
||||
"orderNumber": "예시값",
|
||||
"TotPrice": 1
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-302 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-302 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
PC 표준결제창(KRW)의 인증 결과를 브라우저 POST 로 수신해 서버 승인(2단계)까지 마무리하는 콜백입니다. KG 이니시스 표준결제는 결제창 인증(1단계)과 서버 승인(2단계)이 분리되어 있어, 이 엔드포인트가 콜백으로 받은 `authToken` 을 `authUrl` 로 재전송해 최종 승인을 얻은 뒤 주문을 결제완료 처리하고 결과 페이지로 리다이렉트합니다. 응답은 항상 JSON 이 아니라 리다이렉트(302)이며, 성공 시 결제완료 URL, 실패 시 체크아웃 URL 에 `error`·`message`·`orderId` 쿼리를 붙여 보냅니다.
|
||||
|
||||
주의사항: (1) `authUrl`·`netCancelUrl` 은 콜백으로 들어온 값을 그대로 신뢰하지 않고 `idc_name` 기준 화이트리스트로 검증해 SSRF 를 차단하며, 검증 실패 시 `error=auth_url_invalid` 로 되돌립니다. (2) 승인 응답의 결제수단이 가상계좌(`VBank`)면 결제완료 처리를 하지 않고 계좌 정보만 저장해 입금대기 상태로 두며, 실제 완료는 입금통보(vbank-notify) 시점에 이뤄집니다. (3) 같은 거래번호가 이미 결제완료인 경우 재처리하지 않고 성공 페이지로 복귀합니다(재전송 방어). (4) 승인 후 금액 불일치나 예외가 발생하면 즉시 망취소(net cancel)를 보내 PG 측 승인 잔존을 해제합니다. (5) 사용자가 결제창을 닫은 취소(`2001`/`0021`/`0022`/빈값, 또는 메시지에 '취소'·'사용자' 포함)는 실패가 아니므로 에러 쿼리 없이 체크아웃으로 조용히 복귀합니다.
|
||||
|
||||
예시 시나리오: 구매자가 PC 결제창에서 카드 결제를 승인 → KG 이니시스가 이 콜백으로 `resultCode=0000` + `authToken` + `authUrl` 전송 → 서버가 승인 API 호출 후 주문 결제완료 → `/shop/orders/{orderId}/complete` 로 리다이렉트.
|
||||
|
||||
|
||||
### GET /plugins/sirsoft-pay_kginicis/payment/cbt/callback
|
||||
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.cbt.callback -->
|
||||
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.cbt.callback`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\CbtCallbackController@handle`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| sid | query | string | 아니오 | max 255 | CBT 인증 세션 ID. 서버가 `/cbtapprove` 최종 승인 요청에 MID 와 함께 전송하는 승인 키이며, 결제 메타에 `cbt_sid` 로 저장된다. |
|
||||
| resultCode | query | string | 아니오 | max 100 | CBT 인증 결과 코드 (`OK`/`00`/`0000` = 성공). `2001`/`0021`/`0022` 또는 취소 문구 포함 시 사용자 취소로 간주한다. |
|
||||
| resultMsg | query | string | 아니오 | max 500 | CBT 인증 결과 메시지. 사용자 취소 판별(일본어/한국어 취소 문구)과 실패 리다이렉트 메시지에 사용된다. |
|
||||
| orderID | query | string | 아니오 | max 100 | 주문번호 (1순위 키). `orderId` → `oid` 순으로 폴백해 주문을 조회한다. |
|
||||
| orderId | query | string | 아니오 | max 100 | 주문번호 (2순위 폴백 키). |
|
||||
| oid | query | string | 아니오 | max 100 | 주문번호 (3순위 폴백 키). |
|
||||
| mid | query | string | 아니오 | max 30 | 일본 CBT 가맹점 MID. 설정된 일본 MID 와 불일치하면 결제를 중단한다(위조 콜백 차단). |
|
||||
| paymethod | query | string | 아니오 | max 50 | CBT 결제수단 (CARD / PAYPAY / CVS). 승인 응답의 결제수단과 대조하며, CVS 는 편의점 입금대기 처리로 분기한다. |
|
||||
| selectedPaymentMethod | query | string | 아니오 | max 50 | 체크아웃에서 사용자가 선택한 결제수단 식별값(`card` / `kginicis_japan_paypay` / `kginicis_japan_cvs`). 기대 결제수단 검증과 결제 메타 기록에 사용된다. |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /plugins/sirsoft-pay_kginicis/payment/cbt/callback?sid=%EC%98%88%EC%8B%9C%EA%B0%92&resultCode=%EC%98%88%EC%8B%9C%EA%B0%92&resultMsg=%EC%98%88%EC%8B%9C%EA%B0%92&orderID=%EC%98%88%EC%8B%9C%EA%B0%92&orderId=%EC%98%88%EC%8B%9C%EA%B0%92&oid=%EC%98%88%EC%8B%9C%EA%B0%92&mid=%EC%98%88%EC%8B%9C%EA%B0%92&paymethod=%EC%98%88%EC%8B%9C%EA%B0%92&selectedPaymentMethod=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-200 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
일본 CBT(국경 간 결제) 인증 결과를 수신해 서버 승인(`/cbtapprove`)까지 마무리하는 콜백입니다(GET·POST 동일 처리). 브라우저가 KG 이니시스 일본 결제창에서 인증을 마치면 `sid` 와 주문번호가 붙어 이 주소로 되돌아오고, 서버는 그 `sid` 로 최종 승인을 요청한 뒤 결제완료 또는 편의점 입금대기 처리를 하고 결과 페이지로 리다이렉트합니다. GET 은 결제창이 리다이렉트로 되돌려보내는 경로, POST 는 폼 전송으로 되돌려보내는 경로이며 파라미터 위치만 다릅니다.
|
||||
|
||||
주의사항: (1) 브라우저가 전달한 인증 결과는 무인증 값이므로 그것만으로 주문 상태를 바꾸지 않고, 반드시 서버 승인 응답을 권위 있는 결과로 사용합니다. (2) 콜백의 `mid` 가 설정된 일본 가맹점 MID 와 다르면 위조 콜백으로 보고 즉시 중단합니다(`error=mid_mismatch`). (3) 승인 응답의 주문번호·MID·통화(JPY)·결제수단이 주문·체크아웃 선택값과 모두 일치해야 하며, 하나라도 어긋나면 승인 이후라도 실패로 처리합니다. (4) 결제수단이 `CVS`(편의점)면 결제완료가 아니라 입금대기로 저장하고 확인번호·접수번호·입금기한을 기록하며, 실제 완료는 편의점 입금통보(cvs-notify) 시점입니다. (5) 승인이 확정된 뒤 후속 처리에서 예외가 나면 자동 환불을 시도하고, 환불까지 실패하면 수동 환불 필요 상태로 대사 레코드를 남깁니다. (6) 사용자 취소(취소 코드 또는 일본어·한국어 취소 문구)는 실패가 아니므로 에러 없이 체크아웃으로 복귀하며, PayPay 업스트림 처리 실패는 별도 안내 메시지로 치환됩니다.
|
||||
|
||||
예시 시나리오: 일본 구매자가 PayPay 로 결제 → 결제창이 `sid` 를 붙여 이 콜백으로 복귀 → 서버가 승인 요청 → 성공 시 주문 결제완료 후 완료 페이지로 리다이렉트. 편의점결제를 선택했다면 같은 흐름이지만 입금대기 상태로 남고, 구매자가 편의점에서 입금하면 그때 완료됩니다.
|
||||
|
||||
|
||||
### POST /plugins/sirsoft-pay_kginicis/payment/cbt/callback
|
||||
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.cbt.callback -->
|
||||
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.cbt.callback`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\CbtCallbackController@handle`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| sid | body | string | 아니오 | max 255 | CBT 인증 세션 ID. 서버가 `/cbtapprove` 최종 승인 요청에 MID 와 함께 전송하는 승인 키이며, 결제 메타에 `cbt_sid` 로 저장된다. |
|
||||
| resultCode | body | string | 아니오 | max 100 | CBT 인증 결과 코드 (`OK`/`00`/`0000` = 성공). `2001`/`0021`/`0022` 또는 취소 문구 포함 시 사용자 취소로 간주한다. |
|
||||
| resultMsg | body | string | 아니오 | max 500 | CBT 인증 결과 메시지. 사용자 취소 판별(일본어/한국어 취소 문구)과 실패 리다이렉트 메시지에 사용된다. |
|
||||
| orderID | body | string | 아니오 | max 100 | 주문번호 (1순위 키). `orderId` → `oid` 순으로 폴백해 주문을 조회한다. |
|
||||
| orderId | body | string | 아니오 | max 100 | 주문번호 (2순위 폴백 키). |
|
||||
| oid | body | string | 아니오 | max 100 | 주문번호 (3순위 폴백 키). |
|
||||
| mid | body | string | 아니오 | max 30 | 일본 CBT 가맹점 MID. 설정된 일본 MID 와 불일치하면 결제를 중단한다(위조 콜백 차단). |
|
||||
| paymethod | body | string | 아니오 | max 50 | CBT 결제수단 (CARD / PAYPAY / CVS). 승인 응답의 결제수단과 대조하며, CVS 는 편의점 입금대기 처리로 분기한다. |
|
||||
| selectedPaymentMethod | body | string | 아니오 | max 50 | 체크아웃에서 사용자가 선택한 결제수단 식별값(`card` / `kginicis_japan_paypay` / `kginicis_japan_cvs`). 기대 결제수단 검증과 결제 메타 기록에 사용된다. |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /plugins/sirsoft-pay_kginicis/payment/cbt/callback HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"sid": "예시값",
|
||||
"resultCode": "예시값",
|
||||
"resultMsg": "예시값",
|
||||
"orderID": "예시값",
|
||||
"orderId": "예시값",
|
||||
"oid": "예시값",
|
||||
"mid": "예시값",
|
||||
"paymethod": "예시값",
|
||||
"selectedPaymentMethod": "예시값"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-302 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-302 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
위 `GET /plugins/sirsoft-pay_kginicis/payment/cbt/callback` 과 동일한 CBT 인증 콜백이며, KG 이니시스가 폼 전송(POST)으로 되돌려보내는 경우를 처리합니다. 파라미터가 query 대신 body 로 들어오는 점만 다르고, 승인·검증·리다이렉트 동작과 주의사항은 GET 버전과 완전히 같습니다. 결제창 설정이나 결제수단에 따라 GET/POST 중 어느 쪽으로 돌아올지 달라질 수 있어 두 메서드를 모두 열어 둡니다.
|
||||
|
||||
|
||||
### POST /plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify
|
||||
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.cbt.cvs-notify -->
|
||||
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.cbt.cvs-notify`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\CbtCvsNotifyController@handle`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| tid | body | string | 아니오 | max 80 | CBT 편의점 결제 거래번호. 결제행에 저장된 발급 시점 TID 와 대조해 위조 통보를 차단한다(불일치 시 FAIL). |
|
||||
| mid | body | string | 아니오 | max 20 | 일본 CBT 가맹점 MID. 결제 메타의 `cbt_mid` 와 대조해 불일치 시 통보를 거부한다. |
|
||||
| applDt | body | string | 아니오 | max 8 | 입금 승인 일자(YYYYMMDD). `applTm` 과 결합해 결제 메타의 `auth_date` 로 저장된다. |
|
||||
| applTm | body | string | 아니오 | max 6 | 입금 승인 시각(HHMMSS). `applDt` 와 결합해 결제 메타의 `auth_date` 로 저장된다. |
|
||||
| status | body | string | 아니오 | max 10 | 편의점 입금 통보 상태 코드 (`00` = 입금 완료). 그 외 값은 무시(ignored)하고 OK 로 응답한다. |
|
||||
| payNm | body | string | 아니오 | max 50 | 결제수단명(편의점 결제는 `CVS`). 통보 이력에 원문 그대로 보존된다. |
|
||||
| orderId | body | string | 아니오 | max 100 | 주문번호. 이 값으로 주문·결제행을 조회한다(미존재 시 FAIL). |
|
||||
| applNo | body | string | 아니오 | max 80 | 입금 승인번호. 통보 이력에 원문 그대로 보존된다. |
|
||||
| sid | body | string | 아니오 | max 100 | CBT 인증 세션 ID. 결제 메타의 `cbt_sid`(발급 시점 값)와 대조해 불일치 시 통보를 거부한다. |
|
||||
| convenience | body | string | 아니오 | max 30 | 입금한 편의점 구분값. 결제 메타 `cvs_convenience` 로 갱신된다. |
|
||||
| confNo | body | string | 아니오 | max 80 | 편의점 결제 확인번호. 결제 메타 `cvs_conf_no` 로 갱신된다. |
|
||||
| receiptNo | body | string | 아니오 | max 80 | 편의점 수납 영수증번호. 결제 메타 `cvs_receipt_no` 로 갱신된다. |
|
||||
| paymentTerm | body | string | 아니오 | max 20 | 편의점 입금 기한(YYYYMMDDHHMMSS, 일본 시각). 결제 메타 `cvs_payment_term` 으로 갱신되며 만료 판정 기준이 된다. |
|
||||
| amount | body | string | 아니오 | — | 입금 금액. 주문의 결제 통화(JPY) 청구액과 일치해야 하며(불일치 시 FAIL), 결제 완료 금액으로 사용된다. |
|
||||
| currencyCd | body | string | 아니오 | max 10 | 통보 통화 코드. `JPY` 가 아니면 통보를 거부한다. |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"tid": "예시값",
|
||||
"mid": "예시값",
|
||||
"applDt": "예시값",
|
||||
"applTm": "예시값",
|
||||
"status": "예시값",
|
||||
"payNm": "예시값",
|
||||
"orderId": "예시값",
|
||||
"applNo": "예시값",
|
||||
"sid": "예시값",
|
||||
"convenience": "예시값",
|
||||
"confNo": "예시값",
|
||||
"receiptNo": "예시값",
|
||||
"paymentTerm": "예시값",
|
||||
"amount": "예시값",
|
||||
"currencyCd": "예시값"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-200 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
일본 CBT 편의점(CVS) 결제의 입금 통보(NOTI)를 KG 이니시스 서버로부터 직접 수신하는 웹훅입니다. 구매자가 편의점에서 실제로 대금을 납부하면 KG 이니시스가 이 주소로 통보를 보내고, 서버는 통보를 검증한 뒤 입금대기 상태의 결제를 결제완료로 전환합니다. 이 URL 은 관리자 화면의 CBT 편의점 운영 정보에 표시되며, 그대로 KG 이니시스 가맹점 설정의 통보 URL 로 등록하면 됩니다.
|
||||
|
||||
주의사항: (1) 응답은 JSON 이 아니라 평문 `OK` 또는 `FAIL` 이며(HTTP 200 고정), `OK` 를 돌려주지 않으면 KG 이니시스가 통보를 재시도합니다. 검증 실패는 `FAIL`, 처리 불필요(비-성공 상태 통보·이미 결제완료)는 `OK` 로 응답합니다. (2) 라우트에 KG 이니시스 공식 발송 IP 화이트리스트가 걸려 있어 허용되지 않은 IP 에서 온 요청은 403 으로 차단됩니다(로컬·테스트 환경 제외). (3) 위조 통보를 막기 위해 발급 시점에 저장한 거래번호(TID)·가맹점 MID·세션 ID(SID)와 통보 값을 대조하고, 통화가 `JPY` 인지, 금액이 결제 통화 기준 청구액과 같은지, 결제 상태가 입금대기이고 결제수단이 CVS 인지까지 모두 확인합니다. 어느 하나라도 어긋나면 처리하지 않고 `FAIL` 을 반환합니다. (4) `status` 가 `00`(입금 완료)이 아닌 통보는 무시하고 `OK` 로 응답합니다. (5) 모든 통보는 성공·실패·무시 여부와 사유가 결제 이력에 기록되어 관리자 화면에서 확인할 수 있습니다.
|
||||
|
||||
예시 시나리오: 구매자가 편의점결제를 선택해 확인번호를 발급받고 입금대기 상태가 됨 → 며칠 뒤 편의점에서 납부 → KG 이니시스가 `status=00` 통보를 이 주소로 전송 → 검증 통과 시 주문이 결제완료로 전환되고 서버는 `OK` 를 반환.
|
||||
|
||||
|
||||
### GET /plugins/sirsoft-pay_kginicis/payment/close
|
||||
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.close -->
|
||||
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.close`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\PaymentCloseController@show`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /plugins/sirsoft-pay_kginicis/payment/close HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-200 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
_대표 에러 없음 (공개 조회)._
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
PC 표준결제창의 닫기(closeUrl) 전용 페이지입니다. 구매자가 결제창을 X 버튼으로 닫으면 KG 이니시스 결제창이 이 주소를 열고, 페이지는 KG 이니시스 공식 close 스크립트를 실행해 결제창을 정리한 뒤 부모(또는 opener) 창에 결제창이 닫혔음을 알리는 메시지를 보냅니다. 체크아웃 화면은 그 메시지를 받아 결제 진행 상태를 해제하고, 별도로 결제창 닫힘을 서버에 보고(close-report)합니다.
|
||||
|
||||
주의사항: 응답은 JSON 이 아니라 HTML 페이지이며, 캐시되지 않도록 no-store 헤더가 붙습니다. 로드하는 close 스크립트 주소는 플러그인의 테스트/운영 모드 설정에 따라 달라지므로, 운영 전환 시 결제창 닫기가 정상 동작하는지 함께 확인해야 합니다. 이 엔드포인트는 결제 상태를 바꾸지 않으며, 주문의 취소 이력 기록은 close-report 엔드포인트가 담당합니다.
|
||||
|
||||
|
||||
### GET /plugins/sirsoft-pay_kginicis/payment/escrow-confirm/close
|
||||
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.escrow-confirm.close -->
|
||||
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.escrow-confirm.close`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\UserEscrowConfirmController@close`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /plugins/sirsoft-pay_kginicis/payment/escrow-confirm/close HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-401 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-401 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
_대표 에러 없음 (공개 조회)._
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
에스크로 구매결정 팝업의 닫기(closeUrl) 전용 페이지입니다. 구매자가 PC 에스크로 구매결정 창을 닫으면 KG 이니시스가 이 주소를 열고, 페이지는 창을 닫는 스크립트만 실행합니다. 결제나 구매결정 상태를 바꾸지 않으며 인증도 요구하지 않습니다(팝업 정리 용도). 응답은 JSON 이 아니라 HTML 입니다.
|
||||
|
||||
|
||||
### POST /plugins/sirsoft-pay_kginicis/payment/escrow-confirm/mobile/return
|
||||
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.escrow-confirm.mobile-return -->
|
||||
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.escrow-confirm.mobile-return`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\UserEscrowConfirmController@mobileReturn`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /plugins/sirsoft-pay_kginicis/payment/escrow-confirm/mobile/return HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-302 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-302 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
_대표 에러 없음 (공개 조회)._
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
모바일 에스크로 구매결정 결과를 KG 이니시스로부터 수신하는 복귀 지점입니다. 구매자가 모바일에서 구매확정 또는 구매거절을 마치면 KG 이니시스가 이 주소로 결과를 전송하고, 서버는 전달된 에스크로 거래번호로 해당 결제를 찾아 구매결정 결과(확정/거절, 결과 코드, 메시지)를 결제 이력에 기록한 뒤 마이페이지 주문 상세로 리다이렉트합니다.
|
||||
|
||||
주의사항: 응답은 JSON 이 아니라 리다이렉트(302)이며, 성공 시 해당 주문 상세로, 거래번호로 결제를 찾지 못하면 주문 목록으로 이동합니다. 거래번호가 없거나 매칭되는 에스크로 결제가 없으면 아무것도 기록하지 않고 조용히 넘어갑니다(외부에서 임의 호출해도 상태가 바뀌지 않음). 구매거절이 접수된 경우 판매자 측에서 별도로 구매거절 확인(관리자 에스크로 거절확인) 절차를 진행해야 합니다.
|
||||
|
||||
|
||||
### POST /plugins/sirsoft-pay_kginicis/payment/escrow-confirm/pc/return
|
||||
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.escrow-confirm.pc-return -->
|
||||
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.escrow-confirm.pc-return`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\UserEscrowConfirmController@pcReturn`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /plugins/sirsoft-pay_kginicis/payment/escrow-confirm/pc/return HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-200 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
_대표 에러 없음 (공개 조회)._
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
PC 에스크로 구매결정 결과를 KG 이니시스 팝업으로부터 수신하는 복귀 지점입니다. 구매자가 구매확정 또는 구매거절을 마치면 KG 이니시스 결제창이 이 주소로 결과를 전송하고, 서버는 전달된 에스크로 거래번호로 결제를 찾아 구매결정 결과(확정/거절, 결과 코드, 확정·거절 일시, 거절 사유)를 결제 이력에 기록합니다.
|
||||
|
||||
주의사항: 응답은 JSON 이 아니라 HTML 이며, 팝업을 닫고 부모 창을 새로고침하는 스크립트를 반환합니다. 거래번호가 비어 있거나 매칭되는 에스크로 결제가 없으면 아무 기록도 남기지 않습니다. 결과 코드가 확정(`00`)이 아니면 구매거절로 기록되며, 이후 판매자 측 구매거절 확인 절차가 필요합니다.
|
||||
|
||||
|
||||
### GET /plugins/sirsoft-pay_kginicis/payment/escrow-confirm/{orderNumber}
|
||||
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.escrow-confirm.show -->
|
||||
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.escrow-confirm.show`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\UserEscrowConfirmController@show`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| orderNumber | path | string | 예 | — | 대상 order number의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /plugins/sirsoft-pay_kginicis/payment/escrow-confirm/{orderNumber} HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
구매자가 자신의 에스크로 주문에 대해 구매결정(구매확정/구매거절)을 진행하도록 KG 이니시스 구매결정 창을 띄워 주는 진입 페이지입니다. 마이페이지 주문 상세의 "구매결정" 버튼이 이 주소를 열며, 서버는 접속한 사용자 본인의 KG 이니시스 에스크로 결제를 찾아 거래번호를 담은 폼을 만들고 KG 이니시스 구매결정 창으로 자동 전송합니다. 접속 기기의 User-Agent 로 PC/모바일을 판별해 각각 PC 팝업 방식과 모바일 전송 방식으로 분기합니다.
|
||||
|
||||
주의사항: 응답은 JSON 이 아니라 HTML 페이지입니다. 로그인이 필요하며(비로그인 시 로그인 흐름으로 전환), 주문번호에 해당하는 에스크로 결제가 본인 소유가 아니거나 존재하지 않으면 404 로 응답합니다. 구매결정 결과는 이 페이지가 아니라 KG 이니시스가 되돌려보내는 복귀 지점(escrow-confirm PC/모바일 return)에서 기록됩니다.
|
||||
|
||||
|
||||
### GET /plugins/sirsoft-pay_kginicis/payment/mobile/callback
|
||||
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.mobile.callback -->
|
||||
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.mobile.callback`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\MobileCallbackController@handle`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| P_STATUS | query | string | 예 | — | 모바일 결제 인증 결과 코드 (`00` = 성공). 그 외 값은 실패이며 `P_RMESG1` 문구로 사용자 취소 여부를 분기한다. |
|
||||
| P_RMESG1 | query | string | 아니오 | — | 모바일 결제 인증 결과 메시지. '사용자가 결제를 취소' 등 취소 문구 포함 시 오류 없이 체크아웃으로 복귀한다. |
|
||||
| P_TID | query | string | 아니오 | — | KG 이니시스 거래번호. 서버 승인 요청(`P_REQ_URL`)에 MID 와 함께 전송된다. |
|
||||
| P_REQ_URL | query | string | 아니오 | — | 서버 승인 API 요청 URL. `idc_name` 과 함께 화이트리스트 검증(SSRF 방어)을 통과해야 호출된다. |
|
||||
| P_AMT | query | string | 아니오 | — | 결제 금액. 서버 승인 응답의 `P_AMT` 가 없을 때 결제 완료 금액의 폴백으로 사용된다. |
|
||||
| P_OID | query | string | 아니오 | — | 주문번호. 없으면 콜백 URL 쿼리스트링의 `orderId` 를 폴백으로 사용한다. |
|
||||
| idc_name | query | string | 아니오 | — | KG 이니시스 IDC 센터 코드(fc/ks/stg). 승인 URL 화이트리스트 검증의 기준값. |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /plugins/sirsoft-pay_kginicis/payment/mobile/callback?P_STATUS=%EC%98%88%EC%8B%9C%EA%B0%92&P_RMESG1=%EC%98%88%EC%8B%9C%EA%B0%92&P_TID=%EC%98%88%EC%8B%9C%EA%B0%92&P_REQ_URL=https%3A%2F%2Fexample.com&P_AMT=%EC%98%88%EC%8B%9C%EA%B0%92&P_OID=%EC%98%88%EC%8B%9C%EA%B0%92&idc_name=%EC%98%88%EC%8B%9C%20%EC%9D%B4%EB%A6%84 HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
모바일 표준결제창(KRW)의 인증 결과를 수신해 서버 승인까지 마무리하는 콜백입니다(GET·POST 동일 처리). 모바일 결제는 결제창 인증 후 KG 이니시스가 이 주소로 결과를 돌려보내고, 서버가 전달받은 승인 요청 URL 로 거래번호를 재전송해 최종 승인을 얻은 뒤 주문을 결제완료 처리하고 결과 페이지로 리다이렉트합니다. GET 은 일부 PG 환경의 리다이렉트 복귀 패턴을 위해 함께 열어 둔 경로로, 동작은 POST 와 같습니다.
|
||||
|
||||
주의사항: (1) 응답은 JSON 이 아니라 리다이렉트(302)이며, 실패 시 체크아웃 URL 에 `error`·`message`·`orderId` 쿼리가 붙습니다. (2) 승인 요청 URL 은 콜백 값을 그대로 신뢰하지 않고 IDC 코드 기준 화이트리스트로 검증해 SSRF 를 차단합니다(실패 시 `error=req_url_invalid`). (3) 모바일 표준 응답에는 주문번호가 빠질 수 있어, 없으면 콜백 URL 쿼리스트링의 `orderId` 를 대체로 사용합니다. (4) 승인 결과가 가상계좌면 결제완료 처리를 하지 않고 계좌 정보만 저장해 입금대기로 두며, 완료는 모바일 가상계좌 입금통보 시점입니다. (5) 같은 거래번호가 이미 결제완료면 재처리하지 않고 성공 페이지로 복귀합니다. (6) 승인 후 금액 불일치나 예외가 발생하면 자동 취소를 호출해 PG 측 승인 잔존을 해제하며, 자동 취소마저 실패하면 수동 정산이 필요합니다. (7) 사용자가 결제창을 닫은 취소는 결과 코드만으로 일반 실패와 구분되지 않으므로 결과 메시지의 취소 문구로 판별하며, 이 경우 에러 없이 체크아웃으로 조용히 복귀합니다.
|
||||
|
||||
예시 시나리오: 모바일 구매자가 카드 결제를 승인 → KG 이니시스가 이 콜백으로 인증 성공과 승인 요청 URL 을 전송 → 서버가 승인 요청 → 성공 시 주문 결제완료 후 완료 페이지로 리다이렉트.
|
||||
|
||||
|
||||
### POST /plugins/sirsoft-pay_kginicis/payment/mobile/callback
|
||||
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.mobile.callback -->
|
||||
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.mobile.callback`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\MobileCallbackController@handle`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| P_STATUS | body | string | 예 | — | 모바일 결제 인증 결과 코드 (`00` = 성공). 그 외 값은 실패이며 `P_RMESG1` 문구로 사용자 취소 여부를 분기한다. |
|
||||
| P_RMESG1 | body | string | 아니오 | — | 모바일 결제 인증 결과 메시지. '사용자가 결제를 취소' 등 취소 문구 포함 시 오류 없이 체크아웃으로 복귀한다. |
|
||||
| P_TID | body | string | 아니오 | — | KG 이니시스 거래번호. 서버 승인 요청(`P_REQ_URL`)에 MID 와 함께 전송된다. |
|
||||
| P_REQ_URL | body | string | 아니오 | — | 서버 승인 API 요청 URL. `idc_name` 과 함께 화이트리스트 검증(SSRF 방어)을 통과해야 호출된다. |
|
||||
| P_AMT | body | string | 아니오 | — | 결제 금액. 서버 승인 응답의 `P_AMT` 가 없을 때 결제 완료 금액의 폴백으로 사용된다. |
|
||||
| P_OID | body | string | 아니오 | — | 주문번호. 없으면 콜백 URL 쿼리스트링의 `orderId` 를 폴백으로 사용한다. |
|
||||
| idc_name | body | string | 아니오 | — | KG 이니시스 IDC 센터 코드(fc/ks/stg). 승인 URL 화이트리스트 검증의 기준값. |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /plugins/sirsoft-pay_kginicis/payment/mobile/callback HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"P_STATUS": "예시값",
|
||||
"P_RMESG1": "예시값",
|
||||
"P_TID": "예시값",
|
||||
"P_REQ_URL": "https://example.com",
|
||||
"P_AMT": "예시값",
|
||||
"P_OID": "예시값",
|
||||
"idc_name": "예시 이름"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-302 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-302 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
위 `GET /plugins/sirsoft-pay_kginicis/payment/mobile/callback` 과 동일한 모바일 결제 인증 콜백이며, KG 이니시스가 폼 전송(POST)으로 결과를 돌려보내는 표준 경로입니다. 파라미터가 query 대신 body 로 들어오는 점만 다르고, 서버 승인·화이트리스트 검증·가상계좌 분기·자동 취소·사용자 취소 처리 등 동작과 주의사항은 GET 버전과 동일합니다. 실제 운영에서는 대부분 이 POST 경로로 콜백이 들어옵니다.
|
||||
|
||||
|
||||
### POST /plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify
|
||||
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.mobile.vbank-notify -->
|
||||
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.mobile.vbank-notify`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\PaymentCallbackController@mobileVbankNotify`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| P_TID | body | string | 예 | max 40 | KG 이니시스 거래번호. 결제행에 저장된 발급 시점 TID 와 대조하며, 이미 결제완료된 TID 면 중복 통보로 무시한다. |
|
||||
| P_MID | body | string | 예 | max 10 | 가맹점 MID. 결제 메타에 저장된 발급 시점 MID 와 대조해 위조 통보를 차단한다. |
|
||||
| P_OID | body | string | 예 | max 100 | 주문번호. 이 값으로 입금대기 주문을 조회한다(미존재 시 FAIL). |
|
||||
| P_AMT | body | string | 예 | max 12 | 입금 금액. 주문의 결제 통화 청구액과 일치해야 하며(불일치 시 FAIL), 결제 완료 금액으로 사용된다. |
|
||||
| P_STATUS | body | string | 예 | max 2 | 모바일 통보 상태 코드. `02`(입금통보)일 때만 실제 입금 처리하고, 그 외에는 OK 로 응답 후 무시한다. |
|
||||
| P_TYPE | body | string | 예 | max 10 | 통보 결제수단 구분. `VBANK` 인 경우에만 가상계좌 입금 처리를 수행한다. |
|
||||
| P_AUTH_DT | body | string | 아니오 | max 14 | 입금 승인 일시(YYYYMMDDHHMMSS). 결제 메타의 `deposit_date` 로 저장된다. |
|
||||
| P_FN_CD1 | body | string | 아니오 | max 4 | 입금 은행 코드. 결제 메타의 `bank_code` 로 저장된다. |
|
||||
| P_FN_NM | body | string | 아니오 | max 50 | 입금 은행명(EUC-KR 수신 시 UTF-8 자동 변환). 결제 메타의 `vbank_name` 으로 저장된다. |
|
||||
| P_UNAME | body | string | 아니오 | max 30 | 입금자명(EUC-KR 수신 시 UTF-8 자동 변환). 결제 메타의 `depositor_name` 으로 저장된다. |
|
||||
| P_RMESG1 | body | string | 아니오 | max 500 | 통보 메시지. 파이프(`|`) 구분자의 첫 조각에서 가상계좌번호를 추출해 발급 계좌와 대조하는 데 사용된다. |
|
||||
| P_FN_CD2 | body | string | 아니오 | — | KG 이니시스 부가 은행 코드. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| P_RMESG2 | body | string | 아니오 | — | KG 이니시스 보조 통보 메시지. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| P_NOTI | body | string | 아니오 | max 600 | 결제 요청 시 가맹점이 전달한 임의 데이터의 회신값. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| P_AUTH_NO | body | string | 아니오 | — | 승인번호. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| P_CSHR_AMT | body | string | 아니오 | max 12 | 현금영수증 발급 금액. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| P_CSHR_TAX | body | string | 아니오 | max 12 | 현금영수증 부가세액. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| P_CSHR_TYPE | body | string | 아니오 | max 14 | 현금영수증 발급 구분(소득공제/지출증빙). 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| P_CSHR_DT | body | string | 아니오 | max 14 | 현금영수증 발급 일시. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"P_TID": "예시값",
|
||||
"P_MID": "예시값",
|
||||
"P_OID": "예시값",
|
||||
"P_AMT": "예시값",
|
||||
"P_STATUS": "예시값",
|
||||
"P_TYPE": "예시값",
|
||||
"P_AUTH_DT": "예시값",
|
||||
"P_FN_CD1": "예시값",
|
||||
"P_FN_NM": "예시값",
|
||||
"P_UNAME": "예시 이름",
|
||||
"P_RMESG1": "예시값",
|
||||
"P_FN_CD2": "예시값",
|
||||
"P_RMESG2": "예시값",
|
||||
"P_NOTI": "예시값",
|
||||
"P_AUTH_NO": "예시값",
|
||||
"P_CSHR_AMT": "예시값",
|
||||
"P_CSHR_TAX": "예시값",
|
||||
"P_CSHR_TYPE": "예시값",
|
||||
"P_CSHR_DT": "예시값"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
모바일 결제로 발급한 가상계좌의 입금통보를 KG 이니시스 서버로부터 직접 수신하는 웹훅입니다. 구매자가 발급받은 계좌에 실제로 입금하면 KG 이니시스가 이 주소로 통보를 보내고, 서버는 통보를 검증한 뒤 입금대기 상태의 주문을 결제완료로 전환합니다. 이 URL 은 관리자 설정의 가상계좌 통보 URL(모바일용)에 표시되며, 그대로 KG 이니시스 가맹점 설정에 등록하면 됩니다.
|
||||
|
||||
주의사항: (1) 응답은 JSON 이 아니라 평문 `OK` 또는 `FAIL` 이며, `OK` 를 돌려주지 않으면 KG 이니시스가 통보를 재시도합니다. (2) 라우트에 KG 이니시스 공식 발송 IP 화이트리스트가 걸려 있어 허용되지 않은 IP 의 요청은 403 으로 차단됩니다(로컬·테스트 환경 제외). (3) 위조 통보를 막기 위해 발급 시점에 저장한 거래번호·가맹점 MID·가상계좌번호(숫자만 비교)·결제 통화 기준 청구액을 통보 값과 대조하고, 결제가 가상계좌·입금대기 상태인지도 확인합니다. 하나라도 어긋나면 처리하지 않고 `FAIL` 을 반환합니다. (4) 통보 상태 코드가 입금통보(`02`)이고 결제수단 구분이 `VBANK` 인 경우에만 실제 입금 처리를 하며, 그 외 통보는 `OK` 로 응답 후 무시합니다. (5) 이미 결제완료된 거래번호의 통보는 중복으로 보고 재처리하지 않습니다. (6) 은행명·입금자명이 EUC-KR 로 들어오면 UTF-8 로 자동 변환해 저장합니다.
|
||||
|
||||
예시 시나리오: 구매자가 모바일에서 가상계좌를 선택 → 계좌 발급 후 주문은 입금대기 → 구매자가 계좌 이체 → KG 이니시스가 이 주소로 입금통보 → 검증 통과 시 결제완료 전환 후 `OK` 반환.
|
||||
|
||||
|
||||
### POST /plugins/sirsoft-pay_kginicis/payment/vbank-notify
|
||||
<!-- @generated:start:web.plugins.sirsoft-pay_kginicis.payment.vbank-notify -->
|
||||
- **라우트명**: `web.plugins.sirsoft-pay_kginicis.payment.vbank-notify`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\PaymentCallbackController@vbankNotify`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| no_tid | body | string | 예 | max 40 | 가상계좌 입금통보 거래번호. 결제행에 저장된 발급 시점 TID 와 대조하며, 이미 결제완료된 TID 면 중복 통보로 무시한다. |
|
||||
| no_oid | body | string | 예 | max 40 | 주문번호. 이 값으로 입금대기 주문을 조회한다(미존재 시 FAIL). |
|
||||
| id_merchant | body | string | 예 | max 10 | 가맹점 MID. 결제 메타에 저장된 발급 시점 MID 와 대조해 위조 통보를 차단한다. |
|
||||
| dt_trans | body | string | 예 | max 8 | 입금 거래 일자(YYYYMMDD). `tm_trans` 와 결합해 결제 메타의 `deposit_date` 로 저장된다. |
|
||||
| tm_trans | body | string | 예 | max 6 | 입금 거래 시각(HHMMSS). `dt_trans` 와 결합해 결제 메타의 `deposit_date` 로 저장된다. |
|
||||
| cd_bank | body | string | 예 | max 8 | 입금 은행 코드. 결제 메타의 `bank_code` 로 저장된다. |
|
||||
| cd_deal | body | string | 아니오 | max 8 | KG 이니시스 거래 구분 코드. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| no_vacct | body | string | 예 | max 20 | 입금된 가상계좌번호. 발급 시 저장한 계좌번호와 대조해(숫자만 비교) 위조 통보를 차단하고 결제 메타의 `vbank_num` 으로 저장된다. |
|
||||
| amt_input | body | string | 예 | max 13 | 입금 금액. 주문의 결제 통화 청구액과 일치해야 하며(불일치 시 FAIL), 결제 완료 금액으로 사용된다. |
|
||||
| nm_inputbank | body | string | 아니오 | max 10 | 입금 은행명(EUC-KR 수신 시 UTF-8 자동 변환). 결제 메타의 `vbank_name` 으로 저장된다. |
|
||||
| nm_input | body | string | 아니오 | max 20 | 입금자명(EUC-KR 수신 시 UTF-8 자동 변환). 결제 메타의 `depositor_name` 으로 저장된다. |
|
||||
| dt_inputstd | body | string | 아니오 | max 8 | 입금 기준일자(정산 정보). 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| dt_calculstd | body | string | 아니오 | max 8 | 정산 기준일자(정산 정보). 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| flg_close | body | string | 아니오 | max 1 | 가상계좌 마감 여부 플래그. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| dt_cshr | body | string | 아니오 | max 8 | 현금영수증 발급 일자. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| tm_cshr | body | string | 아니오 | max 6 | 현금영수증 발급 시각. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| no_cshr_appl | body | string | 아니오 | max 9 | 현금영수증 승인번호. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| no_cshr_tid | body | string | 아니오 | max 40 | 현금영수증 거래번호. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| msg_id | body | string | 아니오 | — | KG 이니시스 통보 메시지 ID. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| no_msgseq | body | string | 아니오 | — | KG 이니시스 통보 메시지 일련번호. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| cd_joinorg | body | string | 아니오 | — | KG 이니시스 가입기관 코드. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| dt_transbase | body | string | 아니오 | — | KG 이니시스 거래 기준일자. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| no_transeq | body | string | 아니오 | — | KG 이니시스 거래 일련번호. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| type_msg | body | string | 아니오 | — | KG 이니시스 통보 메시지 유형. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| cl_close | body | string | 아니오 | — | KG 이니시스 마감 구분값. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| cl_kor | body | string | 아니오 | — | KG 이니시스 한글 구분값. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| no_msgmanage | body | string | 아니오 | — | KG 이니시스 통보 메시지 관리번호. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다. |
|
||||
| amt_check | body | string | 아니오 | — | KG 이니시스 검증용 금액값. 통보 차단을 막기 위해 수신만 허용하며 처리에는 사용하지 않는다(입금 금액 검증은 `amt_input` 기준). |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /plugins/sirsoft-pay_kginicis/payment/vbank-notify HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"no_tid": "예시값",
|
||||
"no_oid": "예시값",
|
||||
"id_merchant": "예시값",
|
||||
"dt_trans": "예시값",
|
||||
"tm_trans": "예시값",
|
||||
"cd_bank": "예시값",
|
||||
"cd_deal": "예시값",
|
||||
"no_vacct": "예시값",
|
||||
"amt_input": "예시값",
|
||||
"nm_inputbank": "예시값",
|
||||
"nm_input": "예시값",
|
||||
"dt_inputstd": "예시값",
|
||||
"dt_calculstd": "예시값",
|
||||
"flg_close": "예시값",
|
||||
"dt_cshr": "예시값",
|
||||
"tm_cshr": "예시값",
|
||||
"no_cshr_appl": "예시값",
|
||||
"no_cshr_tid": "예시값",
|
||||
"msg_id": "예시값",
|
||||
"no_msgseq": "예시값",
|
||||
"cd_joinorg": "예시값",
|
||||
"dt_transbase": "예시값",
|
||||
"no_transeq": "예시값",
|
||||
"type_msg": "예시값",
|
||||
"cl_close": "예시값",
|
||||
"cl_kor": "예시값",
|
||||
"no_msgmanage": "예시값",
|
||||
"amt_check": "예시값"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
PC 결제로 발급한 가상계좌의 입금통보를 KG 이니시스 서버로부터 직접 수신하는 웹훅입니다. 구매자가 발급받은 계좌에 실제로 입금하면 KG 이니시스가 이 주소로 통보를 보내고, 서버는 통보를 검증한 뒤 입금대기 상태의 주문을 결제완료로 전환합니다. 이 URL 은 관리자 설정의 가상계좌 통보 URL(PC 웹용)에 표시되며, 그대로 KG 이니시스 가맹점 설정에 등록하면 됩니다.
|
||||
|
||||
주의사항: (1) 응답은 JSON 이 아니라 평문 `OK` 또는 `FAIL` 이며, `OK` 를 돌려주지 않으면 KG 이니시스가 최대 10회까지 통보를 재시도합니다. (2) 라우트에 KG 이니시스 공식 발송 IP 화이트리스트가 걸려 있어 허용되지 않은 IP 의 요청은 403 으로 차단됩니다(로컬·테스트 환경 제외). (3) PC 입금통보에는 별도 서명 필드가 없으므로, 발급 시점에 저장한 거래번호·가맹점 MID·가상계좌번호(숫자만 비교)·결제 통화 기준 청구액을 통보 값과 모두 대조하고 결제가 가상계좌·입금대기 상태인지도 확인해 위조 통보를 걸러냅니다. 하나라도 어긋나면 처리하지 않고 `FAIL` 을 반환합니다. (4) 이미 결제완료된 거래번호의 통보는 중복으로 보고 재처리하지 않고 `OK` 를 반환합니다. (5) 은행명·입금자명이 EUC-KR 로 들어오면 UTF-8 로 자동 변환해 저장합니다. (6) 정산·현금영수증 관련 필드들은 통보가 검증 오류로 차단되지 않도록 수신만 허용하며 처리에는 사용하지 않습니다.
|
||||
|
||||
예시 시나리오: 구매자가 PC 결제창에서 가상계좌를 선택 → 계좌 발급 후 주문은 입금대기 → 구매자가 계좌 이체 → KG 이니시스가 이 주소로 입금통보 → 검증 통과 시 결제완료 전환 후 `OK` 반환.
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Transaction 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
|
||||
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
@@ -38,7 +38,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Vbank 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
|
||||
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
@@ -41,8 +41,8 @@ _단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| url | string | `https://api.example.com/plugins/sirsoft-pay_…` | PC 웹용 가상계좌 입금통보(NOTI) 수신 콜백 URL(`/plugins/sirsoft-pay_kginicis/payment/vbank-notify`)의 절대 주소. 운영자가 이 값을 KG 이니시스 가맹점 설정에 입금통보 URL로 등록하면, 구매자가 가상계좌에 실제 입금했을 때 KG 이니시스가 이 주소로 통보한다. |
|
||||
| mobile_url | string | `https://api.example.com/plugins/sirsoft-pay_…` | mobile URL |
|
||||
| url | string | `https://g7.dev/plugins/sirsoft-pay_kg…` | PC 웹용 가상계좌 입금통보(NOTI) 수신 콜백 URL(`/plugins/sirsoft-pay_kginicis/payment/vbank-notify`)의 절대 주소. 운영자가 이 값을 KG 이니시스 가맹점 설정에 입금통보 URL로 등록하면, 구매자가 가상계좌에 실제 입금했을 때 KG 이니시스가 이 주소로 통보한다. |
|
||||
| mobile_url | string | `https://g7.dev/plugins/sirsoft-pay_kg…` | mobile URL |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'errors' => [
|
||||
'cash_receipt_invalid_issue_type' => 'Invalid cash receipt issue type.',
|
||||
'cash_receipt_missing_issue_number' => 'Please enter the identification number.',
|
||||
'cash_receipt_already_issued' => 'A cash receipt has already been issued for this payment.',
|
||||
'cash_receipt_issue_failed' => 'Failed to issue cash receipt.',
|
||||
'order_not_found' => 'Order not found.',
|
||||
'cbt_failed' => 'Failed to process the cross-border payment.',
|
||||
],
|
||||
'cash_receipt' => [
|
||||
'provider_name' => 'KG Inicis',
|
||||
],
|
||||
'refund' => [
|
||||
'missing_tid' => 'Cannot process refund: transaction ID (TID) is missing.',
|
||||
'default_reason' => 'Buyer refund request',
|
||||
|
||||
@@ -4,13 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'errors' => [
|
||||
'cash_receipt_invalid_issue_type' => '발행 유형이 올바르지 않습니다.',
|
||||
'cash_receipt_missing_issue_number' => '식별번호를 입력해주세요.',
|
||||
'cash_receipt_already_issued' => '이미 현금영수증이 발행된 결제입니다.',
|
||||
'cash_receipt_issue_failed' => '현금영수증 발행에 실패했습니다.',
|
||||
'order_not_found' => '주문을 찾을 수 없습니다.',
|
||||
'cbt_failed' => '해외결제 처리에 실패했습니다.',
|
||||
],
|
||||
'cash_receipt' => [
|
||||
'provider_name' => 'KG이니시스',
|
||||
],
|
||||
'refund' => [
|
||||
'missing_tid' => '거래 ID(TID)가 없어 환불을 진행할 수 없습니다.',
|
||||
'default_reason' => '구매자 환불 요청',
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-pay_kginicis",
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-pay_kginicis",
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-pay_kginicis",
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "KG 이니시스",
|
||||
"en": "KG Inicis"
|
||||
},
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis",
|
||||
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis/blob/main/CHANGELOG.md",
|
||||
@@ -16,7 +16,7 @@
|
||||
"g7_version": ">=7.0.5",
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
"sirsoft-ecommerce": ">=1.0.0"
|
||||
"sirsoft-ecommerce": ">=1.1.0"
|
||||
},
|
||||
"plugins": {}
|
||||
},
|
||||
|
||||
@@ -361,6 +361,7 @@ class Plugin extends AbstractPlugin
|
||||
{
|
||||
return [
|
||||
Listeners\RegisterPgProviderListener::class,
|
||||
Listeners\RegisterCashReceiptProviderListener::class,
|
||||
Listeners\PaymentRefundListener::class,
|
||||
Listeners\CancelActivityLogListener::class,
|
||||
Listeners\RegisterEasyPayMethodsListener::class,
|
||||
|
||||
+1557
-564
File diff suppressed because it is too large
Load Diff
+111
@@ -152,3 +152,114 @@ describe('mypageOrderShowInjector — 회원 주문 상세 (URL 세그먼트 =
|
||||
expect(document.getElementById('kginicis-mp-receipt-row')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 주입 앵커(#order_payment_info_panel) 회귀 잠금 — #454 R1.
|
||||
*
|
||||
* findPaymentContainer() 는 #order_payment_info_panel 을 1순위로 찾는다. 코어 마이페이지
|
||||
* 주문상세에 그 id 가 없던 시절엔 이 경로가 항상 null 이라 주입이 조용히 no-op 이 되었고,
|
||||
* 그 사실이 아무 테스트에도 걸리지 않았다 (기존 테스트는 늘 패널을 만들어 두고 시작했다).
|
||||
*
|
||||
* 그래서 여기서는 (a) 패널이 있으면 그 안에 주입되는가 (b) 패널이 없어도 '결제 정보' 헤딩
|
||||
* 폴백으로 주입되는가 — 두 경로를 모두 잠근다. 한쪽만 잠그면 앵커가 사라져도 폴백이
|
||||
* 가려주어 회귀를 놓친다.
|
||||
*/
|
||||
describe('mypageOrderShowInjector — 주입 앵커 (#order_payment_info_panel)', () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
const ORDER = {
|
||||
id: 501,
|
||||
order_number: '20260712-1111111111',
|
||||
payment: {
|
||||
pg_provider: 'kginicis',
|
||||
payment_status: 'paid',
|
||||
transaction_id: 'StdpayCARDINIpayTest',
|
||||
},
|
||||
};
|
||||
|
||||
const receiptOk = () =>
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
receipt_type: 'inicis_receipt',
|
||||
receipt_url: 'https://iniweb.inicis.com/receipt',
|
||||
receipt_label: '영수증',
|
||||
receipt_view_label: '영수증 조회',
|
||||
payment_method_display_label: '신용카드',
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
document.body.innerHTML = '';
|
||||
delete (window as Record<string, unknown>)[FLAG];
|
||||
delete (window as any).G7Core;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('패널(#order_payment_info_panel)이 있으면 그 안에 영수증 행을 주입한다', async () => {
|
||||
history.pushState(null, '', `/mypage/orders/${ORDER.id}`);
|
||||
setOrderState(ORDER);
|
||||
setPaymentPanel();
|
||||
globalThis.fetch = receiptOk();
|
||||
|
||||
installMypageOrderShowInjector();
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
|
||||
const row = document.getElementById('kginicis-mp-receipt-row');
|
||||
expect(row, '패널이 있는데 영수증 행이 주입되지 않았다').not.toBeNull();
|
||||
|
||||
// 화면 아무 곳이 아니라 결제 정보 패널 "안" 이어야 한다.
|
||||
const panel = document.getElementById('order_payment_info_panel');
|
||||
expect(panel?.contains(row!), '영수증 행이 결제 정보 패널 밖에 붙었다').toBe(true);
|
||||
});
|
||||
|
||||
it('패널 id 가 없어도 "결제 정보" 헤딩 폴백으로 주입된다', async () => {
|
||||
history.pushState(null, '', `/mypage/orders/${ORDER.id}`);
|
||||
setOrderState(ORDER);
|
||||
|
||||
// 앵커 id 가 없는 마이페이지 (R1 이전 상태 재현)
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<div><h3>결제 정보</h3></div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>결제 방법</span>
|
||||
<span>무통장입금</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
globalThis.fetch = receiptOk();
|
||||
|
||||
installMypageOrderShowInjector();
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
|
||||
expect(document.getElementById('order_payment_info_panel')).toBeNull();
|
||||
expect(
|
||||
document.getElementById('kginicis-mp-receipt-row'),
|
||||
'앵커 부재 시 폴백 주입이 동작하지 않았다',
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('주입 지점이 아예 없으면 조용히 아무것도 하지 않는다 (예외 없음)', async () => {
|
||||
history.pushState(null, '', `/mypage/orders/${ORDER.id}`);
|
||||
setOrderState(ORDER);
|
||||
document.body.innerHTML = '<div>결제 정보 패널이 없는 화면</div>';
|
||||
globalThis.fetch = receiptOk();
|
||||
|
||||
installMypageOrderShowInjector();
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
|
||||
expect(document.getElementById('kginicis-mp-receipt-row')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,20 +183,6 @@
|
||||
"paypay": "PayPay",
|
||||
"cvs": "Japan Convenience Store"
|
||||
},
|
||||
"cash_receipt_title": "Cash Receipt Issuance",
|
||||
"cash_receipt_description": "Issue a cash receipt via KG Inicis INIAPI.",
|
||||
"cash_receipt_already_issued": "Already Issued",
|
||||
"cash_receipt_issued_at": "Issued At",
|
||||
"cash_receipt_type_label": "Issue Type",
|
||||
"cash_receipt_type_income": "Income Deduction (Consumer)",
|
||||
"cash_receipt_type_expenditure": "Expenditure Proof (Business)",
|
||||
"cash_receipt_number_label": "Identifier",
|
||||
"cash_receipt_number_placeholder_income": "Phone number or SSN",
|
||||
"cash_receipt_number_placeholder_expenditure": "Business registration number",
|
||||
"cash_receipt_issue_button": "Issue Cash Receipt",
|
||||
"cash_receipt_issuing": "Issuing...",
|
||||
"cash_receipt_success": "Cash receipt has been issued.",
|
||||
"cash_receipt_error": "Failed to issue cash receipt.",
|
||||
"escrow_delivery_title": "Escrow Delivery Registration",
|
||||
"escrow_delivery_description": "Register delivery information for KG Inicis escrow payment.",
|
||||
"escrow_delivery_invoice_label": "Invoice No.",
|
||||
|
||||
@@ -183,20 +183,6 @@
|
||||
"paypay": "PayPay",
|
||||
"cvs": "일본 편의점결제"
|
||||
},
|
||||
"cash_receipt_title": "현금영수증 별도발행",
|
||||
"cash_receipt_description": "KG 이니시스 INIAPI를 통해 현금영수증을 발행합니다.",
|
||||
"cash_receipt_already_issued": "이미 발행됨",
|
||||
"cash_receipt_issued_at": "발행일시",
|
||||
"cash_receipt_type_label": "발행 유형",
|
||||
"cash_receipt_type_income": "소득공제 (소비자용)",
|
||||
"cash_receipt_type_expenditure": "지출증빙 (사업자용)",
|
||||
"cash_receipt_number_label": "식별번호",
|
||||
"cash_receipt_number_placeholder_income": "휴대폰번호 또는 주민등록번호",
|
||||
"cash_receipt_number_placeholder_expenditure": "사업자등록번호",
|
||||
"cash_receipt_issue_button": "현금영수증 발행",
|
||||
"cash_receipt_issuing": "발행 중...",
|
||||
"cash_receipt_success": "현금영수증이 발행되었습니다.",
|
||||
"cash_receipt_error": "현금영수증 발행에 실패했습니다.",
|
||||
"escrow_delivery_title": "에스크로 배송등록",
|
||||
"escrow_delivery_description": "KG 이니시스 에스크로 배송등록을 진행합니다.",
|
||||
"escrow_delivery_invoice_label": "운송장번호",
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Controllers;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
|
||||
/**
|
||||
* KG 이니시스 현금영수증 별도발행 관리자 컨트롤러
|
||||
*
|
||||
* 메뉴얼: https://manual.inicis.com/pay/etc-receipt.html
|
||||
*/
|
||||
class AdminCashReceiptController extends AdminBaseController
|
||||
{
|
||||
use SanitizesPgResponse;
|
||||
|
||||
/** 현금영수증 발행 PG 응답 저장/반환 허용 필드 */
|
||||
private const CASH_RECEIPT_RESPONSE_KEYS = [
|
||||
'resultCode',
|
||||
'resultMsg',
|
||||
'tid',
|
||||
'TID',
|
||||
'cashReceiptNo',
|
||||
'cashReceiptTid',
|
||||
'applNum',
|
||||
'applDate',
|
||||
'applTime',
|
||||
'mid',
|
||||
'MID',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly KgInicisApiService $apiService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* issue
|
||||
*
|
||||
* @param Request $request
|
||||
* @param string $orderNumber
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function issue(Request $request, string $orderNumber): JsonResponse
|
||||
{
|
||||
$issueType = (string) $request->input('issue_type', '0');
|
||||
$issueNumber = trim((string) $request->input('issue_number', ''));
|
||||
|
||||
if (! in_array($issueType, ['0', '1'], true)) {
|
||||
return ResponseHelper::error('messages.failed', 422, ['issue_type' => [__('sirsoft-pay_kginicis::messages.errors.cash_receipt_invalid_issue_type')]]);
|
||||
}
|
||||
|
||||
if ($issueNumber === '') {
|
||||
return ResponseHelper::error('messages.failed', 422, ['issue_number' => [__('sirsoft-pay_kginicis::messages.errors.cash_receipt_missing_issue_number')]]);
|
||||
}
|
||||
|
||||
$payment = DB::table('ecommerce_order_payments as p')
|
||||
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
|
||||
->leftJoin('ecommerce_order_addresses as oa', function ($join) {
|
||||
$join->on('oa.order_id', '=', 'o.id')
|
||||
->where('oa.address_type', '=', 'shipping');
|
||||
})
|
||||
->where('o.order_number', $orderNumber)
|
||||
->where('p.pg_provider', 'kginicis')
|
||||
->whereNotNull('p.transaction_id')
|
||||
->select([
|
||||
'p.id',
|
||||
'p.transaction_id',
|
||||
'p.paid_amount_local',
|
||||
'p.vat_amount',
|
||||
'p.buyer_name',
|
||||
'p.buyer_email',
|
||||
'p.buyer_phone',
|
||||
'p.payment_name',
|
||||
'p.payment_meta',
|
||||
'p.is_cash_receipt_issued',
|
||||
'oa.orderer_name as address_orderer_name',
|
||||
'oa.orderer_email as address_orderer_email',
|
||||
'oa.orderer_phone as address_orderer_phone',
|
||||
])
|
||||
->first();
|
||||
|
||||
if (! $payment) {
|
||||
return ResponseHelper::error('messages.failed', 404, null);
|
||||
}
|
||||
|
||||
if ($payment->is_cash_receipt_issued) {
|
||||
return ResponseHelper::error('messages.failed', 409, ['message' => [__('sirsoft-pay_kginicis::messages.errors.cash_receipt_already_issued')]]);
|
||||
}
|
||||
|
||||
// 구매자 정보는 결제 테이블 → 주문 배송지 순서로 사용한다. PG raw 응답은
|
||||
// 민감정보 최소화를 위해 구매자 연락처 fallback 에 사용하지 않는다.
|
||||
$meta = $payment->payment_meta ? json_decode($payment->payment_meta, true) : [];
|
||||
$rawResponse = $meta['pg_raw_response'] ?? [];
|
||||
|
||||
$buyerName = $payment->buyer_name ?? $payment->address_orderer_name ?? '';
|
||||
$buyerEmail = $payment->buyer_email ?? $payment->address_orderer_email ?? '';
|
||||
$buyerTel = $payment->buyer_phone ?? $payment->address_orderer_phone ?? '';
|
||||
$goodName = $payment->payment_name ?? $rawResponse['goodName'] ?? $rawResponse['goodsName'] ?? __('sirsoft-pay_kginicis::messages.defaults.good_name');
|
||||
|
||||
$price = (int) round((float) $payment->paid_amount_local);
|
||||
|
||||
// 부가세: DB에 저장된 값 우선, 없으면 총액의 10/110 으로 계산
|
||||
$vatAmount = (int) round((float) $payment->vat_amount);
|
||||
if ($vatAmount <= 0) {
|
||||
$vatAmount = (int) round($price / 11);
|
||||
}
|
||||
$supplyPrice = $price - $vatAmount;
|
||||
|
||||
Log::info('KG Inicis: cash receipt issue requested', [
|
||||
'order_number' => $orderNumber,
|
||||
'tid' => $payment->transaction_id,
|
||||
'issue_type' => $issueType,
|
||||
'price' => $price,
|
||||
]);
|
||||
|
||||
try {
|
||||
$pgResponse = $this->apiService->issueCashReceipt([
|
||||
'issueType' => $issueType,
|
||||
'issueNumber' => $issueNumber,
|
||||
'price' => $price,
|
||||
'supplyPrice' => $supplyPrice,
|
||||
'tax' => $vatAmount,
|
||||
'goodName' => $goodName,
|
||||
'buyerName' => $buyerName,
|
||||
'buyerEmail' => $buyerEmail,
|
||||
'buyerTel' => $buyerTel,
|
||||
]);
|
||||
|
||||
$resultCode = $pgResponse['resultCode'] ?? '';
|
||||
|
||||
if ($resultCode !== '00') {
|
||||
Log::warning('KG Inicis: cash receipt issue failed', [
|
||||
'order_number' => $orderNumber,
|
||||
'result_code' => $resultCode,
|
||||
'result_msg' => $pgResponse['resultMsg'] ?? '',
|
||||
]);
|
||||
|
||||
return ResponseHelper::error('messages.failed', 502, [
|
||||
'message' => [$pgResponse['resultMsg'] ?? __('sirsoft-pay_kginicis::messages.errors.cash_receipt_issue_failed')],
|
||||
]);
|
||||
}
|
||||
|
||||
$sanitizedPgResponse = $this->sanitizePgResponse($pgResponse, self::CASH_RECEIPT_RESPONSE_KEYS);
|
||||
|
||||
// DB 업데이트
|
||||
DB::table('ecommerce_order_payments')
|
||||
->where('id', $payment->id)
|
||||
->update([
|
||||
'is_cash_receipt_issued' => true,
|
||||
'cash_receipt_type' => $issueType === '0' ? 'income_deduction' : 'expenditure_proof',
|
||||
'cash_receipt_identifier' => $this->maskCashReceiptIdentifier($issueNumber),
|
||||
'cash_receipt_issued_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Log::info('KG Inicis: cash receipt issued', [
|
||||
'order_number' => $orderNumber,
|
||||
'tid' => $payment->transaction_id,
|
||||
'issue_type' => $issueType,
|
||||
]);
|
||||
|
||||
return ResponseHelper::success('messages.success', [
|
||||
'result_code' => $resultCode,
|
||||
'result_msg' => $pgResponse['resultMsg'] ?? 'OK',
|
||||
'pg_response' => $sanitizedPgResponse,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('KG Inicis: cash receipt issue exception', [
|
||||
'order_number' => $orderNumber,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ResponseHelper::error('messages.failed', 500, [
|
||||
'message' => [$e->getMessage()],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증 발행 식별번호를 저장용 마스킹 값으로 변환합니다.
|
||||
*
|
||||
* @param string $identifier PG 발행 요청에 사용한 원본 식별번호
|
||||
* @return string 끝 4자리만 남긴 마스킹 값
|
||||
*/
|
||||
private function maskCashReceiptIdentifier(string $identifier): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $identifier) ?? '';
|
||||
if ($digits === '') {
|
||||
return str_repeat('*', max(1, mb_strlen($identifier)));
|
||||
}
|
||||
|
||||
$length = strlen($digits);
|
||||
if ($length <= 4) {
|
||||
return str_repeat('*', $length);
|
||||
}
|
||||
|
||||
return str_repeat('*', $length - 4) . substr($digits, -4);
|
||||
}
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
|
||||
/**
|
||||
* 현금영수증 발급 프로바이더 리스너
|
||||
*
|
||||
* 이커머스 모듈의 공용 현금영수증 훅 축(등록/발급/취소)을 구독해 KG이니시스
|
||||
* 현금영수증 API 로 위임합니다. PG 결제사와 독립적으로 선택할 수 있으므로
|
||||
* 토스로 결제하면서 영수증만 KG로 발급하는 구성도 가능합니다.
|
||||
*/
|
||||
class RegisterCashReceiptProviderListener implements HookListenerInterface
|
||||
{
|
||||
use SanitizesPgResponse;
|
||||
|
||||
/**
|
||||
* 현금영수증 프로바이더 식별자
|
||||
*/
|
||||
private const PROVIDER_ID = 'kginicis';
|
||||
|
||||
/**
|
||||
* 발급/취소 PG 응답에서 보존할 필드
|
||||
*/
|
||||
private const RESPONSE_KEYS = [
|
||||
'resultCode',
|
||||
'resultMsg',
|
||||
'tid',
|
||||
'TID',
|
||||
'authNo',
|
||||
'authNumber',
|
||||
'authDate',
|
||||
'authTime',
|
||||
'authPrice',
|
||||
'cshrCancelNum',
|
||||
'mid',
|
||||
'MID',
|
||||
];
|
||||
|
||||
/**
|
||||
* 구독할 훅 매핑 반환
|
||||
*
|
||||
* @return array<string, array<string, mixed>> 훅 구독 설정
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'sirsoft-ecommerce.cash_receipt.registered_providers' => [
|
||||
'method' => 'registerProvider',
|
||||
'type' => 'filter',
|
||||
'priority' => 10,
|
||||
],
|
||||
'sirsoft-ecommerce.cash_receipt.issue' => [
|
||||
'method' => 'issue',
|
||||
'type' => 'filter',
|
||||
'priority' => 10,
|
||||
],
|
||||
'sirsoft-ecommerce.cash_receipt.cancel' => [
|
||||
'method' => 'cancel',
|
||||
'type' => 'filter',
|
||||
'priority' => 10,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 핸들러 (미사용 — 개별 메서드에서 처리)
|
||||
*
|
||||
* @param mixed ...$args 훅 인수
|
||||
*/
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* 현금영수증 프로바이더 목록에 KG이니시스를 등록합니다.
|
||||
*
|
||||
* @param array $providers 기존 프로바이더 목록
|
||||
* @return array KG이니시스가 추가된 목록
|
||||
*/
|
||||
public function registerProvider(array $providers): array
|
||||
{
|
||||
$providers[] = [
|
||||
'id' => self::PROVIDER_ID,
|
||||
'name' => 'KG이니시스',
|
||||
'name_key' => 'sirsoft-pay_kginicis::messages.cash_receipt.provider_name',
|
||||
'icon' => 'receipt',
|
||||
'supports' => [
|
||||
CashReceiptType::INCOME->value,
|
||||
CashReceiptType::EXPENSE->value,
|
||||
],
|
||||
];
|
||||
|
||||
return $providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증을 발급합니다.
|
||||
*
|
||||
* @param array $result 이전 필터 누적 결과
|
||||
* @param Order $order 주문
|
||||
* @param string $providerId 발급 대상 프로바이더 ID
|
||||
* @param array $payload 발급 페이로드 (코어가 조립)
|
||||
* @return array 발급 결과 {success, error_code, error_message, receipt_key, receipt_url, issue_number, raw_response}
|
||||
*/
|
||||
public function issue(array $result, Order $order, string $providerId, array $payload): array
|
||||
{
|
||||
if ($providerId !== self::PROVIDER_ID) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$amount = (int) ($payload['amount'] ?? 0);
|
||||
$taxFree = (int) ($payload['tax_free_amount'] ?? 0);
|
||||
[$supplyPrice, $tax] = $this->splitSupplyAndTax($amount, $taxFree);
|
||||
|
||||
try {
|
||||
$response = app(KgInicisApiService::class)->issueCashReceipt([
|
||||
'issueType' => $this->resolveIssueType($payload['type'] ?? null),
|
||||
'issueNumber' => (string) ($payload['identifier'] ?? ''),
|
||||
'price' => $amount,
|
||||
'supplyPrice' => $supplyPrice,
|
||||
'tax' => $tax,
|
||||
'goodName' => (string) ($payload['order_name'] ?? ''),
|
||||
'buyerName' => $this->resolveBuyerName($order),
|
||||
'buyerEmail' => $this->resolveBuyerEmail($order),
|
||||
'buyerTel' => $this->resolveBuyerTel($order),
|
||||
]);
|
||||
|
||||
$resultCode = $response['resultCode'] ?? '';
|
||||
|
||||
if ($resultCode !== '00') {
|
||||
Log::warning('KG Inicis: cash receipt issue failed', [
|
||||
'order_id' => $order->id,
|
||||
'result_code' => $resultCode,
|
||||
'result_msg' => $response['resultMsg'] ?? '',
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => $resultCode !== '' ? $resultCode : 'PG_API_ERROR',
|
||||
'error_message' => $response['resultMsg']
|
||||
?? __('sirsoft-pay_kginicis::messages.errors.cash_receipt_issue_failed'),
|
||||
'receipt_key' => null,
|
||||
'receipt_url' => null,
|
||||
'issue_number' => null,
|
||||
'raw_response' => $this->sanitizePgResponse($response, self::RESPONSE_KEYS),
|
||||
];
|
||||
}
|
||||
|
||||
Log::info('KG Inicis: cash receipt issued', [
|
||||
'order_id' => $order->id,
|
||||
'tid' => $response['tid'] ?? null,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
// INIAPI 발급 응답의 tid = "현금영수증발급 거래번호". 취소 시 이 값을 쓴다.
|
||||
'receipt_key' => $response['tid'] ?? ($response['TID'] ?? null),
|
||||
// KG 는 영수증 URL 을 내려주지 않는다 (토스와 달리 조회 링크 부재).
|
||||
'receipt_url' => null,
|
||||
'issue_number' => $response['authNo'] ?? ($response['authNumber'] ?? null),
|
||||
'raw_response' => $this->sanitizePgResponse($response, self::RESPONSE_KEYS),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('KG Inicis: cash receipt issue exception', [
|
||||
'order_id' => $order->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => 'PG_API_ERROR',
|
||||
'error_message' => $e->getMessage(),
|
||||
'receipt_key' => null,
|
||||
'receipt_url' => null,
|
||||
'issue_number' => null,
|
||||
'raw_response' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증을 전액취소합니다.
|
||||
*
|
||||
* KG 는 부분취소를 지원하지 않는다 — 전체취소 후 잔액 재발행이 규약이며,
|
||||
* 코어의 재발급 전략과 일치한다.
|
||||
*
|
||||
* @param array $result 이전 필터 누적 결과
|
||||
* @param Order $order 주문
|
||||
* @param string $providerId 발급 프로바이더 ID
|
||||
* @param string $receiptKey 발급 시 받은 현금영수증 TID
|
||||
* @return array 취소 결과 {success, error_code, error_message, receipt_key, raw_response}
|
||||
*/
|
||||
public function cancel(array $result, Order $order, string $providerId, string $receiptKey): array
|
||||
{
|
||||
if ($providerId !== self::PROVIDER_ID) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = app(KgInicisApiService::class)->cancelCashReceipt($receiptKey);
|
||||
|
||||
Log::info('KG Inicis: cash receipt cancelled', [
|
||||
'order_id' => $order->id,
|
||||
'tid' => $receiptKey,
|
||||
'cancel_num' => $response['cshrCancelNum'] ?? null,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'receipt_key' => $receiptKey,
|
||||
'raw_response' => $this->sanitizePgResponse($response, self::RESPONSE_KEYS),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('KG Inicis: cash receipt cancel failed', [
|
||||
'order_id' => $order->id,
|
||||
'tid' => $receiptKey,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => 'PG_API_ERROR',
|
||||
'error_message' => $e->getMessage(),
|
||||
'receipt_key' => $receiptKey,
|
||||
'raw_response' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 발급 용도(income/expense)를 KG issueType(0/1) 으로 변환합니다.
|
||||
*
|
||||
* @param string|null $type 코어 enum 값
|
||||
* @return string KG issueType ('0'=소득공제, '1'=지출증빙)
|
||||
*/
|
||||
private function resolveIssueType(?string $type): string
|
||||
{
|
||||
return CashReceiptType::fromLegacy($type) === CashReceiptType::EXPENSE ? '1' : '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급 금액을 공급가액과 부가세로 나눕니다.
|
||||
*
|
||||
* 면세분은 부가세 계산에서 제외하고 공급가액에 포함시킨다. 반올림 잔차는
|
||||
* 공급가액에 귀속시켜 총액(price = supplyPrice + tax)을 보존한다.
|
||||
*
|
||||
* @param int $amount 발급 총액
|
||||
* @param int $taxFreeAmount 면세 금액
|
||||
* @return array{0: int, 1: int} [공급가액, 부가세]
|
||||
*/
|
||||
private function splitSupplyAndTax(int $amount, int $taxFreeAmount): array
|
||||
{
|
||||
$taxFree = max(0, min($taxFreeAmount, $amount));
|
||||
$taxable = $amount - $taxFree;
|
||||
|
||||
$tax = (int) round($taxable / 11);
|
||||
$supplyPrice = $amount - $tax;
|
||||
|
||||
return [$supplyPrice, $tax];
|
||||
}
|
||||
|
||||
/**
|
||||
* 구매자명을 해석합니다. (결제 → 주문 배송지 순)
|
||||
*
|
||||
* @param Order $order 주문
|
||||
* @return string 구매자명
|
||||
*/
|
||||
private function resolveBuyerName(Order $order): string
|
||||
{
|
||||
return (string) ($order->payment?->buyer_name
|
||||
?? $order->shippingAddress?->orderer_name
|
||||
?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 구매자 이메일을 해석합니다.
|
||||
*
|
||||
* @param Order $order 주문
|
||||
* @return string 구매자 이메일
|
||||
*/
|
||||
private function resolveBuyerEmail(Order $order): string
|
||||
{
|
||||
return (string) ($order->payment?->buyer_email
|
||||
?? $order->shippingAddress?->orderer_email
|
||||
?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 구매자 연락처를 해석합니다.
|
||||
*
|
||||
* @param Order $order 주문
|
||||
* @return string 구매자 연락처
|
||||
*/
|
||||
private function resolveBuyerTel(Order $order): string
|
||||
{
|
||||
return (string) ($order->payment?->buyer_phone
|
||||
?? $order->shippingAddress?->orderer_phone
|
||||
?? '');
|
||||
}
|
||||
}
|
||||
@@ -153,31 +153,23 @@ class KgInicisApiService
|
||||
: ($settings['live_mobile_hash_key'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* isTestMode
|
||||
|
||||
*
|
||||
|
||||
* @return bool
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* 현재 테스트 모드인지 반환합니다.
|
||||
*
|
||||
* @return bool 테스트 모드면 true
|
||||
*/
|
||||
public function isTestMode(): bool
|
||||
{
|
||||
return $this->isTest;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* useEscrowCredentials
|
||||
|
||||
*
|
||||
|
||||
* @param bool $isEscrow
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* 에스크로 여부에 맞는 테스트 자격증명으로 교체합니다.
|
||||
*
|
||||
* 테스트 모드에서만 동작한다 — 에스크로 테스트 MID 는 표준 테스트 키와 다른 inapi 키를 쓴다.
|
||||
*
|
||||
* @param bool $isEscrow 에스크로 결제 여부
|
||||
*/
|
||||
public function useEscrowCredentials(bool $isEscrow): void
|
||||
{
|
||||
if (! $this->isTest) {
|
||||
@@ -229,16 +221,11 @@ class KgInicisApiService
|
||||
: ($this->settingsSnapshot['live_iniapi_iv'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* getMid
|
||||
|
||||
*
|
||||
|
||||
* @return string
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* 현재 모드에 해당하는 가맹점 ID(MID)를 반환합니다.
|
||||
*
|
||||
* @return string 가맹점 ID
|
||||
*/
|
||||
public function getMid(): string
|
||||
{
|
||||
return $this->mid;
|
||||
@@ -275,31 +262,21 @@ class KgInicisApiService
|
||||
return hash('sha256', $signKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* getJapanMid
|
||||
|
||||
*
|
||||
|
||||
* @return string
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* 일본결제(CBT) 가맹점 ID를 반환합니다.
|
||||
*
|
||||
* @return string 일본 MID
|
||||
*/
|
||||
public function getJapanMid(): string
|
||||
{
|
||||
return $this->japanMid;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* isJapanEnabled
|
||||
|
||||
*
|
||||
|
||||
* @return bool
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* 일본결제(CBT) 사용 설정 여부를 반환합니다.
|
||||
*
|
||||
* @return bool 활성화되어 있으면 true
|
||||
*/
|
||||
public function isJapanEnabled(): bool
|
||||
{
|
||||
return $this->japanEnabled;
|
||||
@@ -307,6 +284,8 @@ class KgInicisApiService
|
||||
|
||||
/**
|
||||
* 일본결제(CBT)가 현재 모드에서 실제 요청 가능한 설정인지 확인.
|
||||
*
|
||||
* @return bool 일본결제 활성 + MID/키가 모두 설정되어 있으면 true
|
||||
*/
|
||||
public function isJapanConfigured(): bool
|
||||
{
|
||||
@@ -320,6 +299,9 @@ class KgInicisApiService
|
||||
*
|
||||
* 운영자가 결제 후 테스트/운영 모드 또는 MID 설정을 변경해도 과거 CBT 거래가
|
||||
* 현재 설정의 MID 로 취소 요청되는 회귀를 막는다.
|
||||
*
|
||||
* @param bool $isTest 결제 당시의 테스트 모드 여부
|
||||
* @param string $mid 결제 당시 저장된 일본 MID (빈 값이면 현재 설정에서 재해석)
|
||||
*/
|
||||
public function useStoredCbtCredentials(bool $isTest, string $mid): void
|
||||
{
|
||||
@@ -332,46 +314,31 @@ class KgInicisApiService
|
||||
: (string) ($this->settingsSnapshot['live_japan_sign_key'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* getJsUrl
|
||||
|
||||
*
|
||||
|
||||
* @return string
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* 표준 결제창 JS SDK URL 을 반환합니다. (테스트/운영 분기)
|
||||
*
|
||||
* @return string 결제창 JS URL
|
||||
*/
|
||||
public function getJsUrl(): string
|
||||
{
|
||||
return $this->isTest ? self::JS_URL_TEST : self::JS_URL_LIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* getCbtAuthUrl
|
||||
|
||||
*
|
||||
|
||||
* @return string
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* 일본결제(CBT) 인증 요청 URL 을 반환합니다. (테스트/운영 분기)
|
||||
*
|
||||
* @return string CBT 인증 URL
|
||||
*/
|
||||
public function getCbtAuthUrl(): string
|
||||
{
|
||||
return $this->isTest ? self::CBT_AUTH_URL_TEST : self::CBT_AUTH_URL_LIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* getCbtApproveUrl
|
||||
|
||||
*
|
||||
|
||||
* @return string
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* 일본결제(CBT) 승인 요청 URL 을 반환합니다. (테스트/운영 분기)
|
||||
*
|
||||
* @return string CBT 승인 URL
|
||||
*/
|
||||
public function getCbtApproveUrl(): string
|
||||
{
|
||||
return $this->isTest ? self::CBT_APPROVE_URL_TEST : self::CBT_APPROVE_URL_LIVE;
|
||||
@@ -1030,6 +997,71 @@ class KgInicisApiService
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증 발행취소 API 호출 (INIAPI v2)
|
||||
*
|
||||
* 결제 취소와 같은 refund 엔드포인트를 쓰되, TID 자리에 **현금영수증 발급 TID** 를 넣는다.
|
||||
* 현금영수증 발행건이면 응답에 취소승인번호(cshrCancelNum)가 함께 내려온다.
|
||||
*
|
||||
* 부분취소는 지원하지 않는다 — 전체취소 후 잔액으로 재발행하는 것이 KG 규약이며,
|
||||
* 코어의 재발급 전략(전액취소 → 재발급)과 일치한다.
|
||||
*
|
||||
* 메뉴얼: https://manual.inicis.com/rtpay/cancel.html
|
||||
*
|
||||
* @param string $receiptTid 현금영수증 발급 TID
|
||||
* @param string $msg 취소 사유
|
||||
* @return array PG 응답 (resultCode '00' = 성공, cshrCancelNum = 현금영수증 취소승인번호)
|
||||
*
|
||||
* @throws \Exception API 호출 실패 시
|
||||
*/
|
||||
public function cancelCashReceipt(string $receiptTid, string $msg = '현금영수증 발행취소'): array
|
||||
{
|
||||
$type = 'refund';
|
||||
$timestamp = date('YmdHis');
|
||||
$clientIp = request()->ip() ?? '127.0.0.1';
|
||||
|
||||
$detail = [
|
||||
'tid' => $receiptTid,
|
||||
'msg' => $msg,
|
||||
];
|
||||
|
||||
$detailJson = str_replace('\\/', '/', json_encode($detail, JSON_UNESCAPED_UNICODE));
|
||||
$hashData = hash('sha512', $this->inapiKey.$this->mid.$type.$timestamp.$detailJson);
|
||||
|
||||
$baseUrl = $this->isTest ? self::API_BASE_URL_TEST : self::API_BASE_URL_LIVE;
|
||||
$apiUrl = $baseUrl.'/v2/pg/'.$type;
|
||||
|
||||
$payload = [
|
||||
'mid' => $this->mid,
|
||||
'type' => $type,
|
||||
'timestamp' => $timestamp,
|
||||
'clientIp' => $clientIp,
|
||||
'data' => $detail,
|
||||
'hashData' => $hashData,
|
||||
];
|
||||
|
||||
$response = Http::withHeaders(['Content-Type' => 'application/json;charset=utf-8'])
|
||||
->post($apiUrl, $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new KgInicisApiException('KG Inicis cash receipt cancel API error: HTTP '.$response->status());
|
||||
}
|
||||
|
||||
$result = $response->json() ?? [];
|
||||
|
||||
if (($result['resultCode'] ?? '') !== '00') {
|
||||
Log::error('KG Inicis: cash receipt cancel failed', [
|
||||
'result_code' => $result['resultCode'] ?? 'UNKNOWN',
|
||||
'result_msg' => $result['resultMsg'] ?? '',
|
||||
'tid' => $receiptTid,
|
||||
]);
|
||||
|
||||
throw new KgInicisApiException($result['resultMsg'] ?? 'KG Inicis cash receipt cancel failed');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function buildLiveMid(string $suffix): string
|
||||
{
|
||||
if ($suffix === '') {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Sirsoft\PayKginicis\Controllers\AdminCashReceiptController;
|
||||
use Plugins\Sirsoft\PayKginicis\Controllers\AdminCbtConnectivityCheckController;
|
||||
use Plugins\Sirsoft\PayKginicis\Controllers\AdminCbtCvsOperationsController;
|
||||
use Plugins\Sirsoft\PayKginicis\Controllers\AdminCbtReconciliationController;
|
||||
@@ -106,11 +105,6 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g
|
||||
->middleware('permission:admin,sirsoft-ecommerce.orders.read')
|
||||
->name('orders.cbt-cvs.recheck');
|
||||
|
||||
// 현금영수증 별도발행
|
||||
Route::post('/orders/{orderNumber}/cash-receipt', [AdminCashReceiptController::class, 'issue'])
|
||||
->middleware('permission:admin,sirsoft-ecommerce.orders.update')
|
||||
->name('orders.cash-receipt.issue');
|
||||
|
||||
// 에스크로 배송등록
|
||||
Route::get('/orders/{orderNumber}/escrow-delivery', [AdminEscrowDeliveryController::class, 'formData'])
|
||||
->middleware('permission:admin,sirsoft-ecommerce.orders.read')
|
||||
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Feature\Controllers;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
class AdminCashReceiptControllerTest extends PluginTestCase
|
||||
{
|
||||
public function test_cash_receipt_issue_returns_sanitized_pg_response(): void
|
||||
{
|
||||
$admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
|
||||
$order = OrderFactory::new()->paid()->create([
|
||||
'order_number' => 'ORD-CASH-RECEIPT-001',
|
||||
'total_amount' => 30000,
|
||||
'total_due_amount' => 0,
|
||||
'total_paid_amount' => 30000,
|
||||
]);
|
||||
|
||||
$payment = OrderPaymentFactory::new()->forOrder($order)->create([
|
||||
'payment_status' => PaymentStatusEnum::PAID,
|
||||
'payment_method' => PaymentMethodEnum::VBANK,
|
||||
'pg_provider' => 'kginicis',
|
||||
'transaction_id' => 'INICIS_CASH_TID_001',
|
||||
'paid_amount_local' => 30000,
|
||||
'vat_amount' => 2727,
|
||||
'payment_name' => '테스트 상품',
|
||||
'buyer_name' => '홍길동',
|
||||
'buyer_email' => 'buyer@example.com',
|
||||
'buyer_phone' => '010-1234-5678',
|
||||
'is_cash_receipt_issued' => false,
|
||||
]);
|
||||
|
||||
$mock = $this->createMock(KgInicisApiService::class);
|
||||
$mock->expects($this->once())
|
||||
->method('issueCashReceipt')
|
||||
->willReturn([
|
||||
'resultCode' => '00',
|
||||
'resultMsg' => 'OK',
|
||||
'tid' => 'INICIS_CASH_TID_001',
|
||||
'cashReceiptNo' => 'CR-001',
|
||||
'buyerName' => '홍길동',
|
||||
'buyerTel' => '010-1234-5678',
|
||||
'buyerEmail' => 'buyer@example.com',
|
||||
]);
|
||||
$this->app->instance(KgInicisApiService::class, $mock);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->postJson('/api/plugins/sirsoft-pay_kginicis/admin/orders/ORD-CASH-RECEIPT-001/cash-receipt', [
|
||||
'issue_type' => '0',
|
||||
'issue_number' => '01012345678',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.result_code', '00')
|
||||
->assertJsonPath('data.pg_response.resultCode', '00')
|
||||
->assertJsonMissingPath('data.pg_response.buyerName')
|
||||
->assertJsonMissingPath('data.pg_response.buyerTel')
|
||||
->assertJsonMissingPath('data.pg_response.buyerEmail');
|
||||
|
||||
$payment->refresh();
|
||||
$this->assertSame('*******5678', $payment->cash_receipt_identifier);
|
||||
$this->assertNotSame('01012345678', $payment->cash_receipt_identifier);
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -21,7 +21,6 @@ class AdminRoutePermissionTest extends PluginTestCase
|
||||
'api.plugins.sirsoft-pay_kginicis.admin.orders.cbt-cvs.simulate-notify' => 'permission:admin,sirsoft-ecommerce.orders.update',
|
||||
'api.plugins.sirsoft-pay_kginicis.admin.orders.cbt-cvs.expire' => 'permission:admin,sirsoft-ecommerce.orders.update',
|
||||
'api.plugins.sirsoft-pay_kginicis.admin.orders.cbt-cvs.recheck' => 'permission:admin,sirsoft-ecommerce.orders.read',
|
||||
'api.plugins.sirsoft-pay_kginicis.admin.orders.cash-receipt.issue' => 'permission:admin,sirsoft-ecommerce.orders.update',
|
||||
'api.plugins.sirsoft-pay_kginicis.admin.orders.escrow-delivery.form' => 'permission:admin,sirsoft-ecommerce.orders.read',
|
||||
'api.plugins.sirsoft-pay_kginicis.admin.orders.escrow-delivery.register' => 'permission:admin,sirsoft-ecommerce.orders.update',
|
||||
'api.plugins.sirsoft-pay_kginicis.admin.orders.escrow-deny-confirm' => 'permission:admin,sirsoft-ecommerce.orders.update',
|
||||
@@ -37,16 +36,19 @@ class AdminRoutePermissionTest extends PluginTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_order_update_permission_is_required_for_cash_receipt_issue(): void
|
||||
/**
|
||||
* 현금영수증 발행 라우트가 제거되었는지 확인합니다.
|
||||
*
|
||||
* 발행은 이커머스 모듈의 공용 현금영수증 API 로 이관되었다 (프로바이더 훅 축).
|
||||
* 라우트가 되살아나면 KG 전용 경로가 다시 생겨 이중 발행 경로가 된다.
|
||||
*
|
||||
* @effects legacy_kg_cash_receipt_route_removed
|
||||
*/
|
||||
public function test_legacy_cash_receipt_route_is_removed(): void
|
||||
{
|
||||
$admin = $this->createAdminUser(['sirsoft-ecommerce.orders.read']);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->postJson('/api/plugins/sirsoft-pay_kginicis/admin/orders/ORD-PERM-001/cash-receipt', [
|
||||
'issue_type' => '0',
|
||||
'issue_number' => '01012345678',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertNull(
|
||||
Route::getRoutes()->getByName('api.plugins.sirsoft-pay_kginicis.admin.orders.cash-receipt.issue'),
|
||||
'KG 전용 현금영수증 발행 라우트는 공용 프로바이더 축으로 이관되어 제거되어야 합니다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -3,8 +3,8 @@
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Concerns;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesTimestampFreshness;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* Signature replay 방어용 timestamp 신선도 검증 트레이트 단위 테스트.
|
||||
@@ -15,14 +15,15 @@ use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesTimestampFreshness;
|
||||
* - ±300초 윈도우 내부는 fresh, 이외는 stale
|
||||
* - 잘못된 포맷은 거부
|
||||
*/
|
||||
class ValidatesTimestampFreshnessTest extends TestCase
|
||||
class ValidatesTimestampFreshnessTest extends PluginTestCase
|
||||
{
|
||||
private object $subject;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->subject = new class {
|
||||
$this->subject = new class
|
||||
{
|
||||
use ValidatesTimestampFreshness {
|
||||
isTimestampFresh as public;
|
||||
}
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Listeners;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugins\Sirsoft\PayKginicis\Listeners\AdjustEcommercePaymentMethodsLayoutListener;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 이커머스 결제수단 설정 레이아웃 보정 리스너 테스트 — 이슈 #475
|
||||
@@ -18,7 +18,7 @@ use Plugins\Sirsoft\PayKginicis\Listeners\AdjustEcommercePaymentMethodsLayoutLis
|
||||
* 이제 결제수단 카탈로그가 pg_locked / needs_pg 를 내려주고 코어 레이아웃이 그 값으로 직접
|
||||
* 분기하므로, 이 리스너의 책임은 테스트모드 경고 주입 하나만 남았다.
|
||||
*/
|
||||
class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
class AdjustEcommercePaymentMethodsLayoutListenerTest extends PluginTestCase
|
||||
{
|
||||
public function test_subscribes_to_layout_after_apply_as_filter(): void
|
||||
{
|
||||
|
||||
+21
-12
@@ -4,10 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Listeners;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugins\Sirsoft\PayKginicis\Listeners\EnsureAdminOrderDetailPaymentQueryLayoutListener;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
class EnsureAdminOrderDetailPaymentQueryLayoutListenerTest extends TestCase
|
||||
class EnsureAdminOrderDetailPaymentQueryLayoutListenerTest extends PluginTestCase
|
||||
{
|
||||
public function test_subscribes_to_layout_after_apply_as_filter(): void
|
||||
{
|
||||
@@ -23,9 +23,15 @@ class EnsureAdminOrderDetailPaymentQueryLayoutListenerTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제조회 패널을 탭 콘텐츠 영역에 주입하되, 이관된 KG 전용 현금영수증 패널은
|
||||
* 되살아나지 않는지 확인
|
||||
*
|
||||
* @effects legacy_kg_cash_receipt_panel_removed
|
||||
*/
|
||||
public function test_ensures_payment_query_panel_inside_tab_content_area(): void
|
||||
{
|
||||
$listener = new EnsureAdminOrderDetailPaymentQueryLayoutListener();
|
||||
$listener = new EnsureAdminOrderDetailPaymentQueryLayoutListener;
|
||||
|
||||
$result = $listener->ensurePaymentQueryLayout($this->makeOrderDetailLayout(), 1);
|
||||
$tabContent = $this->findNodeById($result, 'tab_content_area');
|
||||
@@ -41,7 +47,9 @@ class EnsureAdminOrderDetailPaymentQueryLayoutListenerTest extends TestCase
|
||||
$this->assertSame(1, $this->countNodeId($tabContent, 'kginicis_verify_panel'));
|
||||
$this->assertSame(1, $this->countNodeId($tabContent, 'kginicis_escrow_delivery_panel'));
|
||||
$this->assertSame(1, $this->countNodeId($tabContent, 'kginicis_escrow_deny_confirm_panel'));
|
||||
$this->assertSame(1, $this->countNodeId($tabContent, 'kginicis_cash_receipt_panel'));
|
||||
// 현금영수증 발행은 공용 프로바이더 축(이커머스 모듈)으로 이관되었다.
|
||||
// KG 전용 패널이 되살아나면 관리자 화면에 발행 UI 가 두 벌 뜬다.
|
||||
$this->assertSame(0, $this->countNodeId($tabContent, 'kginicis_cash_receipt_panel'));
|
||||
$this->assertSame(0, $this->countDirectChildId($wrapper, 'kginicis_verify_panel'));
|
||||
$this->assertIsString($json);
|
||||
$this->assertStringContainsString('sirsoft-pay_kginicis.admin.order_verify_button', $json);
|
||||
@@ -50,7 +58,7 @@ class EnsureAdminOrderDetailPaymentQueryLayoutListenerTest extends TestCase
|
||||
|
||||
public function test_keeps_payment_query_panel_idempotent(): void
|
||||
{
|
||||
$listener = new EnsureAdminOrderDetailPaymentQueryLayoutListener();
|
||||
$listener = new EnsureAdminOrderDetailPaymentQueryLayoutListener;
|
||||
|
||||
$once = $listener->ensurePaymentQueryLayout($this->makeOrderDetailLayout(), 1);
|
||||
$twice = $listener->ensurePaymentQueryLayout($once, 1);
|
||||
@@ -59,12 +67,12 @@ class EnsureAdminOrderDetailPaymentQueryLayoutListenerTest extends TestCase
|
||||
$this->assertSame(1, $this->countNodeId($twice, 'kginicis_verify_panel'));
|
||||
$this->assertSame(1, $this->countNodeId($twice, 'kginicis_escrow_delivery_panel'));
|
||||
$this->assertSame(1, $this->countNodeId($twice, 'kginicis_escrow_deny_confirm_panel'));
|
||||
$this->assertSame(1, $this->countNodeId($twice, 'kginicis_cash_receipt_panel'));
|
||||
$this->assertSame(0, $this->countNodeId($twice, 'kginicis_cash_receipt_panel'));
|
||||
}
|
||||
|
||||
public function test_uses_order_detail_wrapper_when_tab_content_area_is_renamed(): void
|
||||
{
|
||||
$listener = new EnsureAdminOrderDetailPaymentQueryLayoutListener();
|
||||
$listener = new EnsureAdminOrderDetailPaymentQueryLayoutListener;
|
||||
$layout = $this->makeOrderDetailLayout(includeTabContentArea: false);
|
||||
|
||||
$result = $listener->ensurePaymentQueryLayout($layout, 1);
|
||||
@@ -76,7 +84,7 @@ class EnsureAdminOrderDetailPaymentQueryLayoutListenerTest extends TestCase
|
||||
|
||||
public function test_leaves_other_layouts_unchanged(): void
|
||||
{
|
||||
$listener = new EnsureAdminOrderDetailPaymentQueryLayoutListener();
|
||||
$listener = new EnsureAdminOrderDetailPaymentQueryLayoutListener;
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'admin_ecommerce_order_list',
|
||||
@@ -127,7 +135,7 @@ class EnsureAdminOrderDetailPaymentQueryLayoutListenerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $layout
|
||||
* @param array<string, mixed> $layout
|
||||
*/
|
||||
private function countDataSources(array $layout, string $id): int
|
||||
{
|
||||
@@ -138,7 +146,7 @@ class EnsureAdminOrderDetailPaymentQueryLayoutListenerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
private function countNodeId(array $node, string $id): int
|
||||
{
|
||||
@@ -147,6 +155,7 @@ class EnsureAdminOrderDetailPaymentQueryLayoutListenerTest extends TestCase
|
||||
foreach ($node as $value) {
|
||||
if (is_array($value)) {
|
||||
$count += $this->countNodeId($value, $id);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -159,7 +168,7 @@ class EnsureAdminOrderDetailPaymentQueryLayoutListenerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function findNodeById(array $node, string $id): ?array
|
||||
@@ -184,7 +193,7 @@ class EnsureAdminOrderDetailPaymentQueryLayoutListenerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
private function countDirectChildId(array $node, string $id): int
|
||||
{
|
||||
|
||||
+15
-13
@@ -4,10 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Listeners;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugins\Sirsoft\PayKginicis\Listeners\EnsureAdminOrderDetailTestModeLayoutListener;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
class EnsureAdminOrderDetailTestModeLayoutListenerTest extends TestCase
|
||||
class EnsureAdminOrderDetailTestModeLayoutListenerTest extends PluginTestCase
|
||||
{
|
||||
public function test_subscribes_to_layout_after_apply_as_filter(): void
|
||||
{
|
||||
@@ -25,7 +25,7 @@ class EnsureAdminOrderDetailTestModeLayoutListenerTest extends TestCase
|
||||
|
||||
public function test_ensures_kginicis_payment_query_layout_on_order_detail(): void
|
||||
{
|
||||
$listener = new EnsureAdminOrderDetailTestModeLayoutListener();
|
||||
$listener = new EnsureAdminOrderDetailTestModeLayoutListener;
|
||||
|
||||
$result = $listener->ensureTestModeLayout($this->makeOrderDetailLayout(), 1);
|
||||
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||
@@ -38,7 +38,8 @@ class EnsureAdminOrderDetailTestModeLayoutListenerTest extends TestCase
|
||||
$this->assertSame(1, $this->countNodeId($result, 'kginicis_verify_panel'));
|
||||
$this->assertSame(1, $this->countNodeId($result, 'kginicis_escrow_delivery_panel'));
|
||||
$this->assertSame(1, $this->countNodeId($result, 'kginicis_escrow_deny_confirm_panel'));
|
||||
$this->assertSame(1, $this->countNodeId($result, 'kginicis_cash_receipt_panel'));
|
||||
// 현금영수증 발행은 공용 프로바이더 축으로 이관 — KG 전용 패널은 제거되었다.
|
||||
$this->assertSame(0, $this->countNodeId($result, 'kginicis_cash_receipt_panel'));
|
||||
$this->assertIsString($json);
|
||||
$this->assertStringContainsString('/api/plugins/sirsoft-pay_kginicis/admin/orders/{{route.orderNumber}}/transaction-status', $json);
|
||||
$this->assertStringContainsString('kginicis_status.data?._is_test_mode === true', $json);
|
||||
@@ -62,7 +63,7 @@ class EnsureAdminOrderDetailTestModeLayoutListenerTest extends TestCase
|
||||
|
||||
public function test_keeps_order_detail_payment_query_layout_idempotent(): void
|
||||
{
|
||||
$listener = new EnsureAdminOrderDetailTestModeLayoutListener();
|
||||
$listener = new EnsureAdminOrderDetailTestModeLayoutListener;
|
||||
|
||||
$once = $listener->ensureTestModeLayout($this->makeOrderDetailLayout(), 1);
|
||||
$twice = $listener->ensureTestModeLayout($once, 1);
|
||||
@@ -75,12 +76,12 @@ class EnsureAdminOrderDetailTestModeLayoutListenerTest extends TestCase
|
||||
$this->assertSame(1, $this->countNodeId($twice, 'kginicis_verify_panel'));
|
||||
$this->assertSame(1, $this->countNodeId($twice, 'kginicis_escrow_delivery_panel'));
|
||||
$this->assertSame(1, $this->countNodeId($twice, 'kginicis_escrow_deny_confirm_panel'));
|
||||
$this->assertSame(1, $this->countNodeId($twice, 'kginicis_cash_receipt_panel'));
|
||||
$this->assertSame(0, $this->countNodeId($twice, 'kginicis_cash_receipt_panel'));
|
||||
}
|
||||
|
||||
public function test_uses_order_detail_wrapper_when_tab_content_area_is_renamed(): void
|
||||
{
|
||||
$listener = new EnsureAdminOrderDetailTestModeLayoutListener();
|
||||
$listener = new EnsureAdminOrderDetailTestModeLayoutListener;
|
||||
$layout = $this->makeOrderDetailLayout(includeTabContentArea: false);
|
||||
|
||||
$result = $listener->ensureTestModeLayout($layout, 1);
|
||||
@@ -98,7 +99,7 @@ class EnsureAdminOrderDetailTestModeLayoutListenerTest extends TestCase
|
||||
|
||||
public function test_leaves_other_layouts_unchanged(): void
|
||||
{
|
||||
$listener = new EnsureAdminOrderDetailTestModeLayoutListener();
|
||||
$listener = new EnsureAdminOrderDetailTestModeLayoutListener;
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'admin_ecommerce_order_list',
|
||||
@@ -154,7 +155,7 @@ class EnsureAdminOrderDetailTestModeLayoutListenerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $layout
|
||||
* @param array<string, mixed> $layout
|
||||
*/
|
||||
private function countDataSources(array $layout, string $id): int
|
||||
{
|
||||
@@ -165,7 +166,7 @@ class EnsureAdminOrderDetailTestModeLayoutListenerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
private function countTestModeNotices(array $node): int
|
||||
{
|
||||
@@ -173,7 +174,7 @@ class EnsureAdminOrderDetailTestModeLayoutListenerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
private function countNodeId(array $node, string $id): int
|
||||
{
|
||||
@@ -182,6 +183,7 @@ class EnsureAdminOrderDetailTestModeLayoutListenerTest extends TestCase
|
||||
foreach ($node as $value) {
|
||||
if (is_array($value)) {
|
||||
$count += $this->countNodeId($value, $id);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -194,7 +196,7 @@ class EnsureAdminOrderDetailTestModeLayoutListenerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function findNodeById(array $node, string $id): ?array
|
||||
@@ -219,7 +221,7 @@ class EnsureAdminOrderDetailTestModeLayoutListenerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
private function countDirectChildId(array $node, string $id): int
|
||||
{
|
||||
|
||||
+9
-8
@@ -4,10 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Listeners;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugins\Sirsoft\PayKginicis\Listeners\EnsureAdminOrderListTestBadgeLayoutListener;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
class EnsureAdminOrderListTestBadgeLayoutListenerTest extends TestCase
|
||||
class EnsureAdminOrderListTestBadgeLayoutListenerTest extends PluginTestCase
|
||||
{
|
||||
public function test_subscribes_to_layout_after_apply_as_filter(): void
|
||||
{
|
||||
@@ -25,7 +25,7 @@ class EnsureAdminOrderListTestBadgeLayoutListenerTest extends TestCase
|
||||
|
||||
public function test_ensures_kginicis_test_mode_data_source_and_badge_on_order_list(): void
|
||||
{
|
||||
$listener = new EnsureAdminOrderListTestBadgeLayoutListener();
|
||||
$listener = new EnsureAdminOrderListTestBadgeLayoutListener;
|
||||
|
||||
$layout = $this->makeOrderListLayout([
|
||||
[
|
||||
@@ -48,7 +48,7 @@ class EnsureAdminOrderListTestBadgeLayoutListenerTest extends TestCase
|
||||
|
||||
public function test_keeps_order_list_badge_injection_idempotent(): void
|
||||
{
|
||||
$listener = new EnsureAdminOrderListTestBadgeLayoutListener();
|
||||
$listener = new EnsureAdminOrderListTestBadgeLayoutListener;
|
||||
|
||||
$layout = $this->makeOrderListLayout([]);
|
||||
|
||||
@@ -61,7 +61,7 @@ class EnsureAdminOrderListTestBadgeLayoutListenerTest extends TestCase
|
||||
|
||||
public function test_leaves_other_layouts_unchanged(): void
|
||||
{
|
||||
$listener = new EnsureAdminOrderListTestBadgeLayoutListener();
|
||||
$listener = new EnsureAdminOrderListTestBadgeLayoutListener;
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'admin_ecommerce_settings',
|
||||
@@ -72,7 +72,7 @@ class EnsureAdminOrderListTestBadgeLayoutListenerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $dataSources
|
||||
* @param array<int, array<string, mixed>> $dataSources
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function makeOrderListLayout(array $dataSources): array
|
||||
@@ -124,7 +124,7 @@ class EnsureAdminOrderListTestBadgeLayoutListenerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $layout
|
||||
* @param array<string, mixed> $layout
|
||||
*/
|
||||
private function countDataSources(array $layout, string $id): int
|
||||
{
|
||||
@@ -135,7 +135,7 @@ class EnsureAdminOrderListTestBadgeLayoutListenerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
private function countBadgeConditions(array $node): int
|
||||
{
|
||||
@@ -144,6 +144,7 @@ class EnsureAdminOrderListTestBadgeLayoutListenerTest extends TestCase
|
||||
foreach ($node as $value) {
|
||||
if (is_array($value)) {
|
||||
$count += $this->countBadgeConditions($value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Listeners;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Plugins\Sirsoft\PayKginicis\Listeners\RegisterCashReceiptProviderListener;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* RegisterCashReceiptProviderListener 단위 테스트
|
||||
*
|
||||
* KG이니시스를 공용 현금영수증 프로바이더 축에 등록하고, 발급/취소를 위임하는지 검증한다.
|
||||
*/
|
||||
class RegisterCashReceiptProviderListenerTest extends PluginTestCase
|
||||
{
|
||||
private const PROVIDER_ID = 'kginicis';
|
||||
|
||||
/**
|
||||
* KG 테스트 자격증명을 주입합니다.
|
||||
*
|
||||
* 발급 API 는 식별번호를 AES-128-CBC 로 암호화하므로 INIAPI 키/IV 가 없으면
|
||||
* openssl_encrypt 가 실패한다. 실제 API 서비스를 그대로 쓰되 설정만 주입한다.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$settings = $this->createMock(PluginSettingsService::class);
|
||||
$settings->method('get')->willReturn([
|
||||
'is_test_mode' => true,
|
||||
'test_mid' => 'INIpayTest',
|
||||
'test_sign_key' => 'SU5JTElURV9UUklQTEVERVNfS0VZU1RS',
|
||||
'test_iniapi_key' => 'ItEQKi3rY7uvDS8l',
|
||||
'test_iniapi_iv' => 'HYb3yQ4f65QL89==',
|
||||
]);
|
||||
|
||||
$this->app->instance(PluginSettingsService::class, $settings);
|
||||
$this->app->forgetInstance(KgInicisApiService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급 훅 기본 반환값 (코어 CashReceiptService 와 동형)
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultIssueResult(): array
|
||||
{
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => 'NO_PROVIDER_HANDLED',
|
||||
'error_message' => 'no provider',
|
||||
'receipt_key' => null,
|
||||
'receipt_url' => null,
|
||||
'issue_number' => null,
|
||||
'raw_response' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 훅 기본 반환값
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultCancelResult(): array
|
||||
{
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => 'NO_PROVIDER_HANDLED',
|
||||
'error_message' => 'no provider',
|
||||
'receipt_key' => null,
|
||||
'raw_response' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어가 전달하는 발급 페이로드
|
||||
*
|
||||
* @param array<string, mixed> $overrides 덮어쓸 키
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function issuePayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'type' => 'income',
|
||||
'identifier' => '01012345678',
|
||||
'identifier_type' => 'phone',
|
||||
'amount' => 11000,
|
||||
'tax_free_amount' => 0,
|
||||
'order_name' => '테스트 상품',
|
||||
'order_number' => 'ORD20260712001',
|
||||
'issue_sequence' => 1,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* KG 결제가 달린 주문을 생성합니다.
|
||||
*/
|
||||
private function createKgOrder(): Order
|
||||
{
|
||||
$order = Order::factory()->create();
|
||||
|
||||
OrderPayment::factory()->create([
|
||||
'order_id' => $order->id,
|
||||
'pg_provider' => self::PROVIDER_ID,
|
||||
'transaction_id' => 'KG_TID_1',
|
||||
'buyer_name' => '홍길동',
|
||||
'buyer_email' => 'buyer@example.com',
|
||||
'buyer_phone' => '01099998888',
|
||||
]);
|
||||
|
||||
return $order->fresh(['payment']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급 성공 응답 (INIAPI: tid = 현금영수증 발급 거래번호, authNo = 승인번호)
|
||||
*/
|
||||
private function fakeIssueApi(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v2/pg/receipt' => Http::response([
|
||||
'resultCode' => '00',
|
||||
'resultMsg' => '정상',
|
||||
'tid' => 'CR_TID_1',
|
||||
'authNo' => 'AUTH_555',
|
||||
], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구독 훅 매핑이 3개 훅을 filter 로 선언하는지 확인
|
||||
*/
|
||||
public function test_get_subscribed_hooks_declares_three_filter_hooks(): void
|
||||
{
|
||||
$hooks = RegisterCashReceiptProviderListener::getSubscribedHooks();
|
||||
|
||||
foreach ([
|
||||
'sirsoft-ecommerce.cash_receipt.registered_providers',
|
||||
'sirsoft-ecommerce.cash_receipt.issue',
|
||||
'sirsoft-ecommerce.cash_receipt.cancel',
|
||||
] as $hook) {
|
||||
$this->assertArrayHasKey($hook, $hooks);
|
||||
$this->assertEquals('filter', $hooks[$hook]['type'], "{$hook} 은 filter 여야 합니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로바이더 등록: 기존 목록(토스 등)을 덮어쓰지 않고 자기 항목만 추가하는지 확인
|
||||
*
|
||||
* @effects provider_registered_without_overwriting_others
|
||||
*/
|
||||
public function test_register_provider_appends_without_overwriting(): void
|
||||
{
|
||||
$existing = [['id' => 'tosspayments', 'name' => '토스페이먼츠']];
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->registerProvider($existing);
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
$this->assertEquals('tosspayments', $result[0]['id']);
|
||||
$this->assertEquals(self::PROVIDER_ID, $result[1]['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급: 타 프로바이더 요청은 그대로 통과시키는지 확인
|
||||
*
|
||||
* @effects other_provider_request_passes_through_untouched
|
||||
*/
|
||||
public function test_issue_early_returns_for_other_provider(): void
|
||||
{
|
||||
Http::fake();
|
||||
$order = $this->createKgOrder();
|
||||
$default = $this->defaultIssueResult();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->issue(
|
||||
$default, $order, 'tosspayments', $this->issuePayload()
|
||||
);
|
||||
|
||||
$this->assertSame($default, $result);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급: 성공 시 취소용 TID 를 receipt_key 로, 승인번호를 issue_number 로 반환하는지 확인
|
||||
*
|
||||
* INIAPI 발급 응답의 tid 가 "현금영수증발급 거래번호" 이며 취소 시 이 값을 쓴다.
|
||||
*
|
||||
* @effects issue_response_mapped_to_receipt_key_and_issue_number
|
||||
*/
|
||||
public function test_issue_maps_tid_to_receipt_key_and_auth_no_to_issue_number(): void
|
||||
{
|
||||
$this->fakeIssueApi();
|
||||
$order = $this->createKgOrder();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->issue(
|
||||
$this->defaultIssueResult(), $order, self::PROVIDER_ID, $this->issuePayload()
|
||||
);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('CR_TID_1', $result['receipt_key'], '취소에 쓸 TID 가 receipt_key 여야 합니다');
|
||||
$this->assertEquals('AUTH_555', $result['issue_number']);
|
||||
$this->assertNull($result['error_code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급: 코어 enum(income/expense) 을 KG issueType(0/1) 으로 변환하는지 확인
|
||||
*
|
||||
* @effects receipt_type_converted_to_provider_specific_value
|
||||
*/
|
||||
public function test_issue_converts_type_to_kg_issue_type(): void
|
||||
{
|
||||
$this->fakeIssueApi();
|
||||
$order = $this->createKgOrder();
|
||||
$listener = new RegisterCashReceiptProviderListener;
|
||||
|
||||
$listener->issue($this->defaultIssueResult(), $order, self::PROVIDER_ID, $this->issuePayload(['type' => 'income']));
|
||||
Http::assertSent(fn ($request) => ($request->data()['data']['issueType'] ?? null) === '0');
|
||||
|
||||
$this->fakeIssueApi();
|
||||
$listener->issue($this->defaultIssueResult(), $order, self::PROVIDER_ID, $this->issuePayload(['type' => 'expense']));
|
||||
Http::assertSent(fn ($request) => ($request->data()['data']['issueType'] ?? null) === '1');
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급: 면세금액을 반영해 공급가액/부가세를 산출하는지 확인
|
||||
*
|
||||
* 과세분 11000 중 면세 1000 → 과세대상 10000 → 부가세 909, 공급가액 10091 (총액 보존)
|
||||
*
|
||||
* @effects tax_free_amount_sent_with_issue
|
||||
*/
|
||||
public function test_issue_computes_supply_price_and_tax_with_tax_free_amount(): void
|
||||
{
|
||||
$this->fakeIssueApi();
|
||||
$order = $this->createKgOrder();
|
||||
|
||||
(new RegisterCashReceiptProviderListener)->issue(
|
||||
$this->defaultIssueResult(),
|
||||
$order,
|
||||
self::PROVIDER_ID,
|
||||
$this->issuePayload(['amount' => 11000, 'tax_free_amount' => 1000])
|
||||
);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$d = $request->data()['data'] ?? [];
|
||||
$price = (int) $d['price'];
|
||||
$supply = (int) $d['supplyPrice'];
|
||||
$tax = (int) $d['tax'];
|
||||
|
||||
// 총액 = 공급가액 + 부가세 (면세분은 공급가액에 포함)
|
||||
return $price === 11000 && ($supply + $tax) === 11000 && $tax === 909;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급: PG 가 실패코드를 주면 실패 결과를 반환하는지 확인 (예외 전파 없음)
|
||||
*
|
||||
* @effects issue_failure_returns_result_without_throwing
|
||||
*/
|
||||
public function test_issue_returns_failure_on_pg_result_code(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v2/pg/receipt' => Http::response([
|
||||
'resultCode' => '01',
|
||||
'resultMsg' => '발급 불가',
|
||||
], 200),
|
||||
]);
|
||||
$order = $this->createKgOrder();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->issue(
|
||||
$this->defaultIssueResult(), $order, self::PROVIDER_ID, $this->issuePayload()
|
||||
);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertNotEmpty($result['error_message']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소: 타 프로바이더 요청은 그대로 통과시키는지 확인
|
||||
*
|
||||
* @effects other_provider_request_passes_through_untouched
|
||||
*/
|
||||
public function test_cancel_early_returns_for_other_provider(): void
|
||||
{
|
||||
Http::fake();
|
||||
$order = $this->createKgOrder();
|
||||
$default = $this->defaultCancelResult();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->cancel($default, $order, 'tosspayments', 'CR_TID_1');
|
||||
|
||||
$this->assertSame($default, $result);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소: 발급 TID 로 refund 엔드포인트를 호출하는지 확인
|
||||
*
|
||||
* KG 는 현금영수증 취소도 결제취소와 같은 refund 엔드포인트를 쓰며,
|
||||
* 현금영수증 발행건이면 응답에 취소승인번호(cshrCancelNum)가 내려온다.
|
||||
*
|
||||
* @effects cancel_uses_issue_tid_as_receipt_key
|
||||
*/
|
||||
public function test_cancel_calls_refund_endpoint_with_receipt_tid(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v2/pg/refund' => Http::response([
|
||||
'resultCode' => '00',
|
||||
'resultMsg' => '정상',
|
||||
'cshrCancelNum' => 'CANCEL_777',
|
||||
], 200),
|
||||
]);
|
||||
$order = $this->createKgOrder();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->cancel(
|
||||
$this->defaultCancelResult(), $order, self::PROVIDER_ID, 'CR_TID_1'
|
||||
);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/v2/pg/refund')
|
||||
&& ($request->data()['data']['tid'] ?? null) === 'CR_TID_1';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소: PG 실패 시 실패 결과를 반환하는지 확인
|
||||
*/
|
||||
public function test_cancel_returns_failure_on_pg_error(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v2/pg/refund' => Http::response([
|
||||
'resultCode' => '01',
|
||||
'resultMsg' => '취소 불가',
|
||||
], 200),
|
||||
]);
|
||||
$order = $this->createKgOrder();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->cancel(
|
||||
$this->defaultCancelResult(), $order, self::PROVIDER_ID, 'CR_TID_1'
|
||||
);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('PG_API_ERROR', $result['error_code']);
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Support;
|
||||
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 백엔드가 API 응답으로 내보내는 메시지 키가 실제로 번역되는지 검증한다.
|
||||
*
|
||||
* ResponseHelper::pluginError('messages.x.y') 는 백엔드 lang/{locale}/messages.php
|
||||
* 에서 키를 찾는다. 프론트엔드 resources/lang/{locale}.json 에만 정의된 키는
|
||||
* 해석되지 않아 사용자에게 'sirsoft-pay_kginicis::messages.x.y' 라는 원문 키가
|
||||
* 그대로 노출된다 (관리자 주문상세의 CBT 편의점 404 토스트에서 실제 발생).
|
||||
*
|
||||
* @effects backend_emitted_message_keys_resolve_in_all_locales
|
||||
*/
|
||||
class BackendMessageKeyResolutionTest extends PluginTestCase
|
||||
{
|
||||
private const PLUGIN_PATH = __DIR__.'/../../..';
|
||||
|
||||
/**
|
||||
* 소스에서 백엔드가 emit 하는 messages.* 키를 수집합니다.
|
||||
*
|
||||
* @return array<int, string> 수집된 메시지 키 목록
|
||||
*/
|
||||
private function emittedMessageKeys(): array
|
||||
{
|
||||
$keys = [];
|
||||
$dir = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator(self::PLUGIN_PATH.'/src')
|
||||
);
|
||||
|
||||
foreach ($dir as $file) {
|
||||
if (! $file->isFile() || $file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// emit 형식이 두 가지다:
|
||||
// ResponseHelper::pluginError('messages.x.y') — 접두 없음
|
||||
// __('sirsoft-pay_kginicis::messages.x.y') / ResponseHelper::error() — 네임스페이스 접두
|
||||
// 접두를 선택적으로 허용하지 않으면 후자를 통째로 놓쳐 사각이 생긴다
|
||||
// (실제로 cbt_connectivity.checked 미정의가 이 사각으로 통과했다).
|
||||
preg_match_all(
|
||||
"/'(?:sirsoft-pay_kginicis::)?(messages\.[a-z_]+\.[a-z_0-9]+)'/",
|
||||
(string) file_get_contents($file->getPathname()),
|
||||
$matches
|
||||
);
|
||||
|
||||
foreach ($matches[1] as $key) {
|
||||
$keys[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<int, string>> 로케일 목록
|
||||
*/
|
||||
public static function localeProvider(): array
|
||||
{
|
||||
return [['ko'], ['en']];
|
||||
}
|
||||
|
||||
/**
|
||||
* 백엔드가 내보내는 모든 메시지 키가 각 로케일에서 해석되어야 합니다.
|
||||
*
|
||||
* @dataProvider localeProvider
|
||||
*/
|
||||
public function test_every_emitted_message_key_resolves(string $locale): void
|
||||
{
|
||||
$emitted = $this->emittedMessageKeys();
|
||||
$this->assertNotEmpty($emitted, '백엔드에서 emit 하는 메시지 키를 찾지 못했습니다.');
|
||||
|
||||
$unresolved = [];
|
||||
|
||||
foreach ($emitted as $key) {
|
||||
$namespaced = 'sirsoft-pay_kginicis::'.$key;
|
||||
$translated = __($namespaced, [], $locale);
|
||||
|
||||
// 번역이 없으면 Laravel 은 키 문자열을 그대로 돌려준다.
|
||||
if ($translated === $namespaced) {
|
||||
$unresolved[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$unresolved,
|
||||
sprintf(
|
||||
"[%s] 백엔드 lang/%s/messages.php 에 정의되지 않은 키가 API 응답으로 노출됩니다:\n - %s",
|
||||
$locale,
|
||||
$locale,
|
||||
implode("\n - ", $unresolved)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -7,11 +7,11 @@ namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Upgrades;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftPayKginicis\V1_0_0\Migrations\MigrateNaverpayBrandButtonKey;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
require_once dirname(__DIR__, 3).'/upgrades/data/1.0.0/migrations/MigrateNaverpayBrandButtonKey.php';
|
||||
|
||||
class MigrateNaverpayBrandButtonKeyTest extends TestCase
|
||||
class MigrateNaverpayBrandButtonKeyTest extends PluginTestCase
|
||||
{
|
||||
private string $settingsPath;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0-beta.4] - 2026-05-28
|
||||
## [1.0.0] - 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -28,11 +28,28 @@
|
||||
- 에스크로 결제 시 결제창에 상품 정보(상품명·단가·수량)를 함께 전달합니다. 가상계좌와 계좌이체 모두에 적용됩니다.
|
||||
- 에스크로를 켜면 설정 화면에 운영 안내가 표시됩니다 — 에스크로 주문은 부분취소가 불가하다는 점과, 배송정보는 토스 상점관리자에서 등록해야 한다는 점을 안내하며 상점관리자로 바로 이동할 수 있습니다.
|
||||
|
||||
#### 현금영수증
|
||||
|
||||
- 현금영수증을 토스페이먼츠로 발급할 수 있습니다. 결제는 다른 PG로 받으면서 현금영수증만 토스페이먼츠로 발급하는 구성도 가능합니다.
|
||||
- 부분환불 등으로 주문 금액이 바뀌면 기존 영수증을 취소하고 새 금액으로 다시 발급합니다. 재발급 시에도 중복 없이 처리됩니다.
|
||||
|
||||
#### 영수증 확인
|
||||
|
||||
- 주문상세(구매자·관리자)에서 토스페이먼츠 영수증을 바로 열어볼 수 있습니다. 관리자 화면에서는 가상계좌 입금 정보와 에스크로 여부도 함께 확인할 수 있습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 플러그인 환경설정 화면의 하단 저장 버튼이 스크롤 중에도 화면에 고정되도록 개선.
|
||||
- sirsoft-ecommerce 최소 버전을 1.1.0 으로 상향 (현금영수증 프로바이더 훅 축 · 결제수단 확장점 의존).
|
||||
|
||||
### Fixed
|
||||
|
||||
- 전액 환불이 부분취소로 잘못 요청되던 문제를 수정했습니다. 이미 부분취소한 이력이 있는 주문의 잔액 취소도 정확한 금액으로 처리됩니다.
|
||||
- 면세 상품이 포함된 주문을 환불할 때 면세 금액이 함께 전달되지 않아 부가세가 잘못 계산되던 문제를 수정했습니다.
|
||||
- 가상계좌 결제를 취소할 때 환불받을 계좌 정보가 전달되지 않아 취소가 실패하던 문제를 수정했습니다. 계좌 정보가 없으면 취소 전에 안내합니다.
|
||||
- 에스크로 결제는 부분취소가 불가하므로, 시도 시 결제사 호출 전에 차단하고 안내합니다.
|
||||
- 네트워크 재시도로 같은 취소 요청이 두 번 전달되어도 중복 취소되지 않도록 했습니다.
|
||||
|
||||
## [1.0.0-beta.3] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "plugins/sirsoft-tosspayments",
|
||||
"description": "TossPayments PG Plugin for G7 platform",
|
||||
"version": "1.0.0-beta.4",
|
||||
"version": "1.0.0",
|
||||
"type": "library",
|
||||
"authors": [
|
||||
{
|
||||
|
||||
@@ -7,6 +7,13 @@ return [
|
||||
'refund' => [
|
||||
'missing_payment_key' => 'Cannot process refund: TossPayments payment key not found.',
|
||||
'default_reason' => 'Cancelled by customer request',
|
||||
'escrow_partial_not_allowed' => 'Escrow payments do not support partial cancellation. Only full cancellation is available.',
|
||||
'missing_refund_account' => 'Cancelling a virtual account payment requires refund account details (bank, account number, holder name).',
|
||||
],
|
||||
'cash_receipt' => [
|
||||
'provider_name' => 'TossPayments',
|
||||
'invalid_order_id' => 'The cash receipt identifier does not match the TossPayments format (6-64 chars, letters/digits/-/_).',
|
||||
'cancel_reason' => 'Reissued due to an order amount change',
|
||||
],
|
||||
'settings_validation' => [
|
||||
'vbank_valid_hours_range' => 'Virtual account deposit deadline must be between :min and :max hours (max 90 days).',
|
||||
|
||||
@@ -7,6 +7,13 @@ return [
|
||||
'refund' => [
|
||||
'missing_payment_key' => '토스페이먼츠 결제 키가 존재하지 않아 환불 처리할 수 없습니다.',
|
||||
'default_reason' => '고객 요청에 의한 취소',
|
||||
'escrow_partial_not_allowed' => '에스크로 결제는 부분 취소를 지원하지 않습니다. 전체 취소만 가능합니다.',
|
||||
'missing_refund_account' => '가상계좌 결제를 취소하려면 환불받을 계좌 정보(은행, 계좌번호, 예금주)가 필요합니다.',
|
||||
],
|
||||
'cash_receipt' => [
|
||||
'provider_name' => '토스페이먼츠',
|
||||
'invalid_order_id' => '현금영수증 발급 식별자가 토스페이먼츠 형식(영문·숫자·-·_ 6~64자)에 맞지 않습니다.',
|
||||
'cancel_reason' => '주문 금액 변경에 따른 재발급',
|
||||
],
|
||||
'settings_validation' => [
|
||||
'vbank_valid_hours_range' => '가상계좌 입금기한은 :min~:max시간(최대 90일) 사이여야 합니다.',
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-tosspayments",
|
||||
"version": "0.2.2",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-tosspayments",
|
||||
"version": "0.2.2",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-tosspayments",
|
||||
"version": "1.0.0-beta.4",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "토스페이먼츠",
|
||||
"en": "TossPayments"
|
||||
},
|
||||
"version": "1.0.0-beta.4",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "토스페이먼츠 결제 게이트웨이 (통합결제창 연동)",
|
||||
|
||||
@@ -241,9 +241,11 @@ class Plugin extends AbstractPlugin
|
||||
return [
|
||||
Listeners\RegisterPgProviderListener::class,
|
||||
Listeners\RegisterTossPaymentMethodsListener::class,
|
||||
Listeners\RegisterCashReceiptProviderListener::class,
|
||||
Listeners\AdjustEcommercePaymentMethodsLayoutListener::class,
|
||||
Listeners\PaymentRefundListener::class,
|
||||
Listeners\ValidateTossSettingsListener::class,
|
||||
Listeners\RestoreLayoutExtensionsAfterUpdateListener::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -271,6 +273,22 @@ class Plugin extends AbstractPlugin
|
||||
'en' => 'After TossPayments confirm API completed',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'sirsoft-tosspayments.payment.before_cancel',
|
||||
'type' => 'action',
|
||||
'description' => [
|
||||
'ko' => '토스페이먼츠 결제 취소 API 호출 전',
|
||||
'en' => 'Before TossPayments cancel API call',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'sirsoft-tosspayments.payment.after_cancel',
|
||||
'type' => 'action',
|
||||
'description' => [
|
||||
'ko' => '토스페이먼츠 결제 취소 완료 후',
|
||||
'en' => 'After TossPayments cancel API completed',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"target_layout": "admin_ecommerce_order_detail",
|
||||
"injections": [
|
||||
{
|
||||
"target_id": "tab_content_area",
|
||||
"position": "append_child",
|
||||
"components": [
|
||||
{
|
||||
"id": "tosspayments_payment_panel",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(order.data?.payments?.filter(p => p.pg_provider === 'tosspayments' && p.transaction_id) ?? []).length > 0}}",
|
||||
"props": {
|
||||
"className": "admin-card"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "tosspayments_payment_header",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-center gap-2 mb-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "w-1.5 h-4 bg-blue-500 rounded-full flex-shrink-0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "text-sm font-semibold text-gray-800 dark:text-gray-200"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.admin.payment_panel_title"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tosspayments_payment_rows",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "space-y-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "tosspayments_escrow_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!order.data?.payment?.is_escrow}}",
|
||||
"props": {
|
||||
"className": "flex-between text-sm"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label-subtle"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.admin.escrow_label"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.admin.escrow_yes"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tosspayments_vbank_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!order.data?.payment?.vbank_number}}",
|
||||
"props": {
|
||||
"className": "flex-between text-sm"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label-subtle"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.admin.vbank_label"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-primary font-medium"
|
||||
},
|
||||
"text": "{{(order.data?.payment?.vbank_name ?? '') + ' ' + (order.data?.payment?.vbank_number ?? '') + ' (' + (order.data?.payment?.vbank_holder ?? '') + ')'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tosspayments_vbank_due_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!order.data?.payment?.vbank_due_at_formatted}}",
|
||||
"props": {
|
||||
"className": "flex-between text-sm"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label-subtle"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.admin.vbank_due_label"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-primary font-medium"
|
||||
},
|
||||
"text": "{{order.data?.payment?.vbank_due_at_formatted ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tosspayments_receipt_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!order.data?.payment?.pg_receipt_url}}",
|
||||
"props": {
|
||||
"className": "flex-between text-sm"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label-subtle"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.admin.receipt_label"
|
||||
},
|
||||
{
|
||||
"id": "tosspayments_receipt_link",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "{{order.data.payment.pg_receipt_url}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"className": "text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.admin.receipt_view"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"target_layout": "mypage/orders/show",
|
||||
"injections": [
|
||||
{
|
||||
"target_id": "order_payment_info_panel",
|
||||
"position": "append_child",
|
||||
"components": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{order.data.payment?.pg_provider === 'tosspayments' && !!order.data.payment?.pg_receipt_url}}",
|
||||
"props": {
|
||||
"className": "pt-4 mt-2 border-t border-gray-200 dark:border-gray-700 space-y-3"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!order.data.payment?.is_escrow}}",
|
||||
"props": {
|
||||
"className": "flex-between"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label-subtle"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.user.payment_type_label"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.user.escrow_yes"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-between"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label-subtle"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.user.receipt_label"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "{{order.data.payment.pg_receipt_url}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"className": "text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.user.receipt_view"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{order.data.payment?.pg_provider === 'tosspayments' && !!order.data.cash_receipt?.receipt_url}}",
|
||||
"props": {
|
||||
"className": "pt-4 mt-2 border-t border-gray-200 dark:border-gray-700 flex-between"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label-subtle"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.user.cash_receipt_label"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "{{order.data.cash_receipt.receipt_url}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"className": "text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.user.cash_receipt_view"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -73,5 +73,22 @@
|
||||
"order_not_found": "Order not found.",
|
||||
"payment_failed": "Payment failed.",
|
||||
"non_krw_method": "This payment method is available for KRW payments only."
|
||||
},
|
||||
"user": {
|
||||
"payment_type_label": "Payment type",
|
||||
"escrow_yes": "Escrow",
|
||||
"receipt_label": "Receipt",
|
||||
"receipt_view": "View receipt",
|
||||
"cash_receipt_label": "Cash receipt",
|
||||
"cash_receipt_view": "View cash receipt"
|
||||
},
|
||||
"admin": {
|
||||
"payment_panel_title": "TossPayments payment info",
|
||||
"escrow_label": "Payment type",
|
||||
"escrow_yes": "Escrow",
|
||||
"vbank_label": "Virtual account",
|
||||
"vbank_due_label": "Deposit deadline",
|
||||
"receipt_label": "Receipt",
|
||||
"receipt_view": "View receipt"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,5 +73,22 @@
|
||||
"order_not_found": "주문을 찾을 수 없습니다.",
|
||||
"payment_failed": "결제에 실패했습니다.",
|
||||
"non_krw_method": "이 결제수단은 원화(KRW) 결제에만 사용할 수 있습니다."
|
||||
},
|
||||
"user": {
|
||||
"payment_type_label": "결제 유형",
|
||||
"escrow_yes": "에스크로",
|
||||
"receipt_label": "영수증",
|
||||
"receipt_view": "영수증 보기",
|
||||
"cash_receipt_label": "현금영수증",
|
||||
"cash_receipt_view": "현금영수증 보기"
|
||||
},
|
||||
"admin": {
|
||||
"payment_panel_title": "토스페이먼츠 결제 정보",
|
||||
"escrow_label": "결제 유형",
|
||||
"escrow_yes": "에스크로",
|
||||
"vbank_label": "가상계좌",
|
||||
"vbank_due_label": "입금 기한",
|
||||
"receipt_label": "영수증",
|
||||
"receipt_view": "영수증 보기"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 PG API 호출 실패 예외
|
||||
*
|
||||
* 결제 승인/취소, 현금영수증 발급/취소 등 토스페이먼츠 API 호출 단계에서 발생하는
|
||||
* 모든 실패 (HTTP 오류, 응답 파싱 실패 등) 를 단일 도메인 예외로 통합한다.
|
||||
*
|
||||
* 베이스 \Exception 직접 throw 대신 본 클래스를 사용해 외부 소비자가 토스페이먼츠
|
||||
* 도메인 오류만 선택적으로 catch 할 수 있도록 한다. RuntimeException 을 상속하므로
|
||||
* 기존의 catch (\Exception) 소비자는 그대로 동작한다.
|
||||
*/
|
||||
class TossPaymentsApiException extends RuntimeException {}
|
||||
@@ -4,8 +4,10 @@ namespace Plugins\Sirsoft\Tosspayments\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderRefund;
|
||||
use Plugins\Sirsoft\Tosspayments\Services\TossPaymentsApiService;
|
||||
|
||||
/**
|
||||
@@ -50,8 +52,9 @@ class PaymentRefundListener implements HookListenerInterface
|
||||
* @param array $result 환불 결과 (기본값)
|
||||
* @param Order $order 주문
|
||||
* @param OrderPayment $payment 결제 정보
|
||||
* @param float $refundAmount 환불 금액
|
||||
* @param float $refundAmount 환불 금액 (결제 통화 order_currency 기준)
|
||||
* @param string|null $reason 환불 사유
|
||||
* @param OrderRefund|null $refund 이번 환불 시도의 레코드 (멱등키 조립용)
|
||||
* @return array 환불 결과 {success, error_code, error_message, transaction_id}
|
||||
*/
|
||||
public function processRefund(
|
||||
@@ -60,6 +63,7 @@ class PaymentRefundListener implements HookListenerInterface
|
||||
OrderPayment $payment,
|
||||
float $refundAmount,
|
||||
?string $reason = null,
|
||||
?OrderRefund $refund = null,
|
||||
): array {
|
||||
// 토스페이먼츠 결제가 아닌 경우 스킵
|
||||
if ($payment->pg_provider !== self::PG_PROVIDER_ID) {
|
||||
@@ -76,19 +80,69 @@ class PaymentRefundListener implements HookListenerInterface
|
||||
];
|
||||
}
|
||||
|
||||
// $refundAmount 는 코어가 결제 통화(order_currency)로 환산해 전달한 실환불액이다.
|
||||
$cancelAmount = (int) round($refundAmount);
|
||||
$isPartial = $this->isPartialCancel($payment, $cancelAmount);
|
||||
|
||||
// E4: 에스크로 결제는 부분취소를 지원하지 않는다. 결제 시점 스냅샷(is_escrow)이 기준이므로
|
||||
// 플러그인 설정을 나중에 off 로 바꿔도 기존 에스크로 주문은 계속 차단된다.
|
||||
if ($isPartial && (bool) $payment->is_escrow) {
|
||||
Log::warning('토스페이먼츠 에스크로 주문의 부분취소 차단', [
|
||||
'order_id' => $order->id,
|
||||
'payment_key' => $paymentKey,
|
||||
'cancel_amount' => $cancelAmount,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => 'ESCROW_PARTIAL_REFUND_NOT_ALLOWED',
|
||||
'error_message' => __('sirsoft-tosspayments::messages.refund.escrow_partial_not_allowed'),
|
||||
'transaction_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// R3: 가상계좌는 입금된 돈을 돌려보낼 계좌가 있어야 취소가 성립한다.
|
||||
// 계좌 없이 호출하면 토스가 거부하므로 API 호출 전에 명시적으로 실패시킨다.
|
||||
$refundReceiveAccount = null;
|
||||
if ($this->requiresRefundReceiveAccount($payment)) {
|
||||
$refundReceiveAccount = $this->buildRefundReceiveAccount($payment);
|
||||
|
||||
if ($refundReceiveAccount === null) {
|
||||
Log::warning('토스페이먼츠 가상계좌 환불계좌 누락', [
|
||||
'order_id' => $order->id,
|
||||
'payment_key' => $paymentKey,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => 'MISSING_REFUND_ACCOUNT',
|
||||
'error_message' => __('sirsoft-tosspayments::messages.refund.missing_refund_account'),
|
||||
'transaction_id' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$apiService = $this->getApiService();
|
||||
|
||||
$cancelReason = $reason ?? __('sirsoft-tosspayments::messages.refund.default_reason');
|
||||
// $refundAmount 는 코어가 결제 통화(order_currency)로 환산해 전달한 실환불액이다.
|
||||
$cancelAmount = (int) $refundAmount;
|
||||
|
||||
$response = $apiService->cancelPayment($paymentKey, $cancelReason, $cancelAmount);
|
||||
$response = $apiService->cancelPayment(
|
||||
$paymentKey,
|
||||
$cancelReason,
|
||||
// R1: 전액취소는 cancelAmount 를 넣지 않는다 (토스: 미지정 = 전액취소).
|
||||
$isPartial ? $cancelAmount : null,
|
||||
// R2: 복합과세 상점에서 면세분이 공급가액으로 오산되지 않도록 동반 전달.
|
||||
$this->resolveTaxFreeAmount($order, $cancelAmount),
|
||||
$refundReceiveAccount,
|
||||
$this->buildIdempotencyKey($refund),
|
||||
);
|
||||
|
||||
Log::info('토스페이먼츠 결제 취소 성공', [
|
||||
'order_id' => $order->id,
|
||||
'payment_key' => $paymentKey,
|
||||
'cancel_amount' => $cancelAmount,
|
||||
'cancel_amount' => $isPartial ? $cancelAmount : null,
|
||||
'is_partial' => $isPartial,
|
||||
]);
|
||||
|
||||
// 토스 응답에서 취소 트랜잭션 ID 추출
|
||||
@@ -119,6 +173,134 @@ class PaymentRefundListener implements HookListenerInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 취소가 부분취소인지 판정합니다. (R1)
|
||||
*
|
||||
* 결제 통화 기준으로 누적 취소액을 본다. 이미 부분취소한 이력이 있으면 잔액 전부를
|
||||
* 취소하더라도 부분취소로 보내야 한다 — 토스는 cancelAmount 미지정 시 "결제 전액" 을
|
||||
* 취소하므로, 이미 취소된 금액까지 두 번 취소하게 되기 때문이다.
|
||||
*
|
||||
* @param OrderPayment $payment 결제 레코드
|
||||
* @param int $cancelAmount 이번 취소 금액 (결제 통화)
|
||||
* @return bool 부분취소 여부
|
||||
*/
|
||||
private function isPartialCancel(OrderPayment $payment, int $cancelAmount): bool
|
||||
{
|
||||
$paidAmount = (int) round((float) $payment->paid_amount_local);
|
||||
$cumulativeCancelled = (int) round($this->cancelledLocalAmount($payment));
|
||||
|
||||
// 코어가 이번 환불분을 이미 누적했을 수 있으므로 이전 누적분만 분리한다.
|
||||
$previousCancelled = max(0, $cumulativeCancelled - $cancelAmount);
|
||||
|
||||
return $previousCancelled > 0 || $cancelAmount < $paidAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제 통화(order_currency) 기준 누적 취소액을 반환합니다.
|
||||
*
|
||||
* @param OrderPayment $payment 결제 레코드
|
||||
* @return float 결제 통화 기준 누적 취소액
|
||||
*/
|
||||
private function cancelledLocalAmount(OrderPayment $payment): float
|
||||
{
|
||||
$currency = $payment->currency;
|
||||
$mc = $payment->mc_cancelled_amount ?? [];
|
||||
|
||||
if ($currency !== null && isset($mc[$currency])) {
|
||||
return (float) $mc[$currency];
|
||||
}
|
||||
|
||||
return (float) $payment->cancelled_amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 금액 중 면세 금액을 산출합니다. (R2)
|
||||
*
|
||||
* 면세/과세 분류의 SSoT 는 주문(total_tax_free_amount / total_tax_amount)이다.
|
||||
* 부분취소면 취소 비율만큼 면세분을 안분한다. 면세분이 없으면 null 을 반환해
|
||||
* 요청 본문에서 taxFreeAmount 자체를 생략시킨다.
|
||||
*
|
||||
* @param Order $order 주문
|
||||
* @param int $cancelAmount 이번 취소 금액
|
||||
* @return int|null 취소분에 해당하는 면세 금액 (없으면 null)
|
||||
*/
|
||||
private function resolveTaxFreeAmount(Order $order, int $cancelAmount): ?int
|
||||
{
|
||||
$taxFree = (int) round((float) $order->total_tax_free_amount);
|
||||
|
||||
if ($taxFree <= 0 || $cancelAmount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$taxable = (int) round((float) $order->total_tax_amount);
|
||||
$classified = $taxFree + $taxable;
|
||||
|
||||
if ($classified <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($cancelAmount >= $classified) {
|
||||
return $taxFree;
|
||||
}
|
||||
|
||||
// 반올림 잔차는 과세 쪽에 귀속시켜 합계를 보존한다 (코어 calculateIssuableAmount 와 동일 규약).
|
||||
$share = (int) round($cancelAmount * $taxFree / $classified);
|
||||
|
||||
return max(0, min($share, $cancelAmount));
|
||||
}
|
||||
|
||||
/**
|
||||
* 환불계좌가 필요한 결제인지 판정합니다. (R3)
|
||||
*
|
||||
* @param OrderPayment $payment 결제 레코드
|
||||
* @return bool 가상계좌 결제 여부
|
||||
*/
|
||||
private function requiresRefundReceiveAccount(OrderPayment $payment): bool
|
||||
{
|
||||
return $payment->payment_method === PaymentMethodEnum::VBANK;
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스 취소 API 의 refundReceiveAccount 페이로드를 조립합니다. (R3)
|
||||
*
|
||||
* @param OrderPayment $payment 결제 레코드
|
||||
* @return array|null 환불계좌 (3필드 중 하나라도 없으면 null)
|
||||
*/
|
||||
private function buildRefundReceiveAccount(OrderPayment $payment): ?array
|
||||
{
|
||||
$bank = $payment->refund_bank_code;
|
||||
$account = $payment->refund_bank_account;
|
||||
$holder = $payment->refund_bank_holder;
|
||||
|
||||
if (empty($bank) || empty($account) || empty($holder)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'bank' => (string) $bank,
|
||||
'accountNumber' => (string) $account,
|
||||
'holderName' => (string) $holder,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 요청의 멱등키를 조립합니다.
|
||||
*
|
||||
* 환불 레코드는 환불 시도마다 고유하므로, 네트워크 재시도로 같은 취소가 두 번
|
||||
* 도달해도 토스가 중복 청구를 차단한다. 레코드가 없으면(레거시 호출) 키를 생략한다.
|
||||
*
|
||||
* @param OrderRefund|null $refund 환불 레코드
|
||||
* @return string|null 멱등키 (없으면 null)
|
||||
*/
|
||||
private function buildIdempotencyKey(?OrderRefund $refund): ?string
|
||||
{
|
||||
if ($refund === null || $refund->id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'g7-refund-'.$refund->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 서비스 인스턴스를 가져옵니다.
|
||||
*/
|
||||
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Plugins\Sirsoft\Tosspayments\Services\TossPaymentsApiService;
|
||||
|
||||
/**
|
||||
* 현금영수증 발급 프로바이더 리스너
|
||||
*
|
||||
* 이커머스 모듈의 현금영수증 훅 축(등록/발급/취소)을 구독해 토스페이먼츠
|
||||
* 현금영수증 API 로 위임합니다. PG 결제사와 독립적으로 선택할 수 있으므로
|
||||
* KG이니시스로 결제하면서 영수증만 토스로 발급하는 구성도 가능합니다.
|
||||
*/
|
||||
class RegisterCashReceiptProviderListener implements HookListenerInterface
|
||||
{
|
||||
/**
|
||||
* 현금영수증 프로바이더 식별자
|
||||
*/
|
||||
private const PROVIDER_ID = 'tosspayments';
|
||||
|
||||
/**
|
||||
* 토스 orderId 제약 — 6~64자, 영문·숫자·`-`·`_`
|
||||
*/
|
||||
private const ORDER_ID_PATTERN = '/^[A-Za-z0-9_-]{6,64}$/';
|
||||
|
||||
/**
|
||||
* 구독할 훅 매핑 반환
|
||||
*
|
||||
* @return array 훅 구독 설정
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'sirsoft-ecommerce.cash_receipt.registered_providers' => [
|
||||
'method' => 'registerProvider',
|
||||
'type' => 'filter',
|
||||
'priority' => 10,
|
||||
],
|
||||
'sirsoft-ecommerce.cash_receipt.issue' => [
|
||||
'method' => 'issue',
|
||||
'type' => 'filter',
|
||||
'priority' => 10,
|
||||
],
|
||||
'sirsoft-ecommerce.cash_receipt.cancel' => [
|
||||
'method' => 'cancel',
|
||||
'type' => 'filter',
|
||||
'priority' => 10,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 핸들러 (미사용 — 개별 메서드에서 처리)
|
||||
*
|
||||
* @param mixed ...$args 훅 인수
|
||||
*/
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* 현금영수증 프로바이더 목록에 토스페이먼츠를 등록합니다.
|
||||
*
|
||||
* @param array $providers 기존 프로바이더 목록
|
||||
* @return array 토스페이먼츠가 추가된 목록
|
||||
*/
|
||||
public function registerProvider(array $providers): array
|
||||
{
|
||||
$providers[] = [
|
||||
'id' => self::PROVIDER_ID,
|
||||
'name' => '토스페이먼츠',
|
||||
'name_key' => 'sirsoft-tosspayments::messages.cash_receipt.provider_name',
|
||||
'icon' => 'receipt',
|
||||
'supports' => [
|
||||
CashReceiptType::INCOME->value,
|
||||
CashReceiptType::EXPENSE->value,
|
||||
],
|
||||
];
|
||||
|
||||
return $providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증을 발급합니다.
|
||||
*
|
||||
* @param array $result 이전 필터 누적 결과
|
||||
* @param Order $order 주문
|
||||
* @param string $providerId 발급 대상 프로바이더 ID
|
||||
* @param array $payload 발급 페이로드 (코어가 조립)
|
||||
* @return array 발급 결과 {success, error_code, error_message, receipt_key, receipt_url, issue_number, raw_response}
|
||||
*/
|
||||
public function issue(array $result, Order $order, string $providerId, array $payload): array
|
||||
{
|
||||
if ($providerId !== self::PROVIDER_ID) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$orderId = $this->buildCashReceiptOrderId($payload);
|
||||
|
||||
if ($orderId === null) {
|
||||
Log::warning('토스페이먼츠 현금영수증 orderId 제약 위반', [
|
||||
'order_id' => $order->id,
|
||||
'order_number' => $payload['order_number'] ?? null,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => 'INVALID_ORDER_ID',
|
||||
'error_message' => __('sirsoft-tosspayments::messages.cash_receipt.invalid_order_id'),
|
||||
'receipt_key' => null,
|
||||
'receipt_url' => null,
|
||||
'issue_number' => null,
|
||||
'raw_response' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$body = [
|
||||
'amount' => (int) ($payload['amount'] ?? 0),
|
||||
'orderId' => $orderId,
|
||||
'orderName' => (string) ($payload['order_name'] ?? ''),
|
||||
'customerIdentityNumber' => (string) ($payload['identifier'] ?? ''),
|
||||
// 토스는 한글 문자열을 요구한다. 코어 enum(income/expense) 변환은 여기서만 수행한다.
|
||||
'type' => $this->resolveTossType($payload['type'] ?? null),
|
||||
];
|
||||
|
||||
$taxFree = (int) ($payload['tax_free_amount'] ?? 0);
|
||||
if ($taxFree > 0) {
|
||||
$body['taxFreeAmount'] = $taxFree;
|
||||
}
|
||||
|
||||
try {
|
||||
// orderId 는 발급 회차(-cr{seq})를 포함해 이 발급 시도에 고유하고, 재시도 간에도
|
||||
// 동일하다 — 멱등키로 그대로 쓰면 재시도로 인한 이중 발급을 토스가 차단한다.
|
||||
$response = $this->getApiService()->issueCashReceipt($body, $orderId);
|
||||
|
||||
Log::info('토스페이먼츠 현금영수증 발급 성공', [
|
||||
'order_id' => $order->id,
|
||||
'receipt_key' => $response['receiptKey'] ?? null,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'receipt_key' => $response['receiptKey'] ?? null,
|
||||
'receipt_url' => $response['receiptUrl'] ?? null,
|
||||
'issue_number' => $response['issueNumber'] ?? null,
|
||||
'raw_response' => $response,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('토스페이먼츠 현금영수증 발급 실패', [
|
||||
'order_id' => $order->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => 'PG_API_ERROR',
|
||||
'error_message' => $e->getMessage(),
|
||||
'receipt_key' => null,
|
||||
'receipt_url' => null,
|
||||
'issue_number' => null,
|
||||
'raw_response' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증을 전액취소합니다.
|
||||
*
|
||||
* 부분취소는 사용하지 않는다 (D7) — 금액 인자가 없다.
|
||||
*
|
||||
* @param array $result 이전 필터 누적 결과
|
||||
* @param Order $order 주문
|
||||
* @param string $providerId 발급 프로바이더 ID
|
||||
* @param string $receiptKey 취소할 영수증 키
|
||||
* @return array 취소 결과 {success, error_code, error_message, receipt_key, raw_response}
|
||||
*/
|
||||
public function cancel(array $result, Order $order, string $providerId, string $receiptKey): array
|
||||
{
|
||||
if ($providerId !== self::PROVIDER_ID) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
// 취소 대상 영수증 키는 재시도 간에 안정적이다 — 멱등키로 쓰면 재시도가
|
||||
// 이미 취소된 영수증을 다시 취소하려다 실패하는 것을 토스가 차단한다.
|
||||
$response = $this->getApiService()->cancelCashReceipt(
|
||||
$receiptKey,
|
||||
__('sirsoft-tosspayments::messages.cash_receipt.cancel_reason'),
|
||||
'cr-cancel-'.$receiptKey,
|
||||
);
|
||||
|
||||
Log::info('토스페이먼츠 현금영수증 취소 성공', [
|
||||
'order_id' => $order->id,
|
||||
'receipt_key' => $receiptKey,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
// 응답의 receiptKey 는 'c_' 접두가 붙은 "취소 거래" 키다. 취소 행에는
|
||||
// 취소된 영수증의 키를 그대로 보관해야 코어의 filterActive() 가
|
||||
// 발급 행과 짝지어 비활성화할 수 있다 (안 그러면 영구히 활성으로 남는다).
|
||||
'receipt_key' => $receiptKey,
|
||||
'raw_response' => $response,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('토스페이먼츠 현금영수증 취소 실패', [
|
||||
'order_id' => $order->id,
|
||||
'receipt_key' => $receiptKey,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => 'PG_API_ERROR',
|
||||
'error_message' => $e->getMessage(),
|
||||
'receipt_key' => $receiptKey,
|
||||
'raw_response' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급 요청의 orderId 를 조립합니다.
|
||||
*
|
||||
* 현금영수증의 orderId 는 결제의 orderId 와 같은 네임스페이스가 아니다. 재발급 시
|
||||
* 동일 orderId 를 재사용하면 토스가 중복 거부하므로 발급 회차를 접미한다.
|
||||
* 코어는 회차 숫자(issue_sequence)만 제공하고, 문자열 조립은 프로바이더가 한다.
|
||||
*
|
||||
* @param array $payload 발급 페이로드
|
||||
* @return string|null 토스 제약을 만족하는 orderId (위반 시 null)
|
||||
*/
|
||||
private function buildCashReceiptOrderId(array $payload): ?string
|
||||
{
|
||||
$orderNumber = (string) ($payload['order_number'] ?? '');
|
||||
$sequence = max(1, (int) ($payload['issue_sequence'] ?? 1));
|
||||
|
||||
$orderId = "{$orderNumber}-cr{$sequence}";
|
||||
|
||||
return preg_match(self::ORDER_ID_PATTERN, $orderId) === 1 ? $orderId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 발급 용도(income/expense)를 토스가 요구하는 한글 문자열로 변환합니다.
|
||||
*
|
||||
* @param string|null $type 코어 enum 값
|
||||
* @return string 토스 type (소득공제 | 지출증빙)
|
||||
*/
|
||||
private function resolveTossType(?string $type): string
|
||||
{
|
||||
return CashReceiptType::fromLegacy($type) === CashReceiptType::EXPENSE
|
||||
? '지출증빙'
|
||||
: '소득공제';
|
||||
}
|
||||
|
||||
/**
|
||||
* API 서비스 인스턴스를 가져옵니다.
|
||||
*/
|
||||
protected function getApiService(): TossPaymentsApiService
|
||||
{
|
||||
return app(TossPaymentsApiService::class);
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Contracts\Repositories\LayoutRepositoryInterface;
|
||||
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
||||
use App\Enums\LayoutExtensionType;
|
||||
use App\Enums\LayoutSourceType;
|
||||
use App\Services\LayoutExtensionService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 플러그인 업데이트 후 레이아웃 확장(오버레이) 복원 리스너
|
||||
*
|
||||
* 업데이트 과정에서 오버레이 주입이 소실되면 주문상세의 영수증 링크·가상계좌 정보가
|
||||
* 조용히 사라진다. 업데이트 완료 훅에서 resources/extensions/*.json 을 다시 등록한다.
|
||||
*/
|
||||
class RestoreLayoutExtensionsAfterUpdateListener implements HookListenerInterface
|
||||
{
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
|
||||
|
||||
/**
|
||||
* 구독할 훅 매핑 반환
|
||||
*
|
||||
* @return array<string, array<string, mixed>> 훅 구독 설정
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.plugins.updated' => [
|
||||
'method' => 'restoreCurrentExtensionsAfterUpdate',
|
||||
'type' => 'action',
|
||||
'priority' => 20,
|
||||
// 인프라 부수효과 — 업데이트 트랜잭션 안에서 동기 실행되어야 한다.
|
||||
'sync' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 핸들러 (미사용 — 개별 메서드에서 처리)
|
||||
*
|
||||
* @param mixed ...$args 훅 인수
|
||||
*/
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* 업데이트된 플러그인이 자신이면 오버레이 확장을 재등록합니다.
|
||||
*
|
||||
* @param string $identifier 업데이트된 플러그인 식별자
|
||||
*/
|
||||
public function restoreCurrentExtensionsAfterUpdate(string $identifier): void
|
||||
{
|
||||
if ($identifier !== self::PLUGIN_IDENTIFIER) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(LayoutExtensionService::class);
|
||||
|
||||
foreach ($this->currentOverlayExtensions() as $extensionData) {
|
||||
$targetLayout = $extensionData['target_layout'] ?? null;
|
||||
if (! is_string($targetLayout) || $targetLayout === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->templateIdsForLogicalLayout($targetLayout) as $templateId) {
|
||||
$result = $service->registerExtension(
|
||||
$extensionData,
|
||||
LayoutSourceType::Plugin,
|
||||
self::PLUGIN_IDENTIFIER,
|
||||
$templateId,
|
||||
true
|
||||
);
|
||||
|
||||
if ($result !== null) {
|
||||
$service->invalidateExtensionCache($templateId, $targetLayout, LayoutExtensionType::Overlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* resources/extensions 의 오버레이 확장 정의를 모두 읽습니다.
|
||||
*
|
||||
* @return array<int, array<string, mixed>> 확장 정의 목록
|
||||
*/
|
||||
private function currentOverlayExtensions(): array
|
||||
{
|
||||
$paths = glob($this->extensionsDirectory().'/*.json') ?: [];
|
||||
$extensions = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
$extensionData = $this->readExtensionJson($path);
|
||||
if ($extensionData === null || ! isset($extensionData['target_layout'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extensions[] = $extensionData;
|
||||
}
|
||||
|
||||
return $extensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 정의 디렉토리 경로를 반환합니다.
|
||||
*/
|
||||
private function extensionsDirectory(): string
|
||||
{
|
||||
return dirname(__DIR__, 2).'/resources/extensions';
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 JSON 을 읽어 배열로 반환합니다.
|
||||
*
|
||||
* @param string $path 파일 경로
|
||||
* @return array<string, mixed>|null 파싱 결과 (실패 시 null)
|
||||
*/
|
||||
private function readExtensionJson(string $path): ?array
|
||||
{
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
Log::warning('토스페이먼츠 레이아웃 확장 파일을 읽을 수 없습니다.', [
|
||||
'path' => $path,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($data)) {
|
||||
Log::warning('토스페이먼츠 레이아웃 확장 JSON 파싱 실패', [
|
||||
'path' => $path,
|
||||
'error' => json_last_error_msg(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 논리 레이아웃명을 가진 템플릿 ID 목록을 반환합니다.
|
||||
*
|
||||
* @param string $layoutName 논리 레이아웃명
|
||||
* @return array<int, int> 템플릿 ID 목록
|
||||
*/
|
||||
private function templateIdsForLogicalLayout(string $layoutName): array
|
||||
{
|
||||
$templateRepository = app(TemplateRepositoryInterface::class);
|
||||
$layoutRepository = app(LayoutRepositoryInterface::class);
|
||||
$templateIds = [];
|
||||
|
||||
foreach ($templateRepository->getAll() as $template) {
|
||||
$layoutNames = $layoutRepository->getLayoutNamesByTemplateId((int) $template->id);
|
||||
|
||||
foreach ($layoutNames as $storedName) {
|
||||
if (! is_string($storedName) || ! $this->matchesLogicalLayout($storedName, $layoutName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templateIds[(int) $template->id] = (int) $template->id;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($templateIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 레이아웃명이 논리 레이아웃명과 일치하는지 확인합니다.
|
||||
*
|
||||
* @param string $storedName 저장된 레이아웃명
|
||||
* @param string $layoutName 논리 레이아웃명
|
||||
* @return bool 일치 여부
|
||||
*/
|
||||
private function matchesLogicalLayout(string $storedName, string $layoutName): bool
|
||||
{
|
||||
return $storedName === $layoutName || str_ends_with($storedName, '.'.$layoutName);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Services;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Plugins\Sirsoft\Tosspayments\Exceptions\TossPaymentsApiException;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 API 호출 서비스
|
||||
@@ -24,7 +26,7 @@ class TossPaymentsApiService
|
||||
private string $secretKey;
|
||||
|
||||
/**
|
||||
* @param PluginSettingsService $pluginSettingsService 플러그인 설정 서비스
|
||||
* @param PluginSettingsService $pluginSettingsService 플러그인 설정 서비스
|
||||
*/
|
||||
public function __construct(PluginSettingsService $pluginSettingsService)
|
||||
{
|
||||
@@ -38,11 +40,12 @@ class TossPaymentsApiService
|
||||
/**
|
||||
* 결제 승인 API 호출
|
||||
*
|
||||
* @param string $paymentKey 토스 결제 키
|
||||
* @param string $orderId 주문번호
|
||||
* @param int $amount 결제 금액
|
||||
* @param string $paymentKey 토스 결제 키
|
||||
* @param string $orderId 주문번호
|
||||
* @param int $amount 결제 금액
|
||||
* @return array PG 응답 데이터
|
||||
* @throws \Exception API 호출 실패 시
|
||||
*
|
||||
* @throws TossPaymentsApiException API 호출 실패 시
|
||||
*/
|
||||
public function confirmPayment(string $paymentKey, string $orderId, int $amount): array
|
||||
{
|
||||
@@ -56,47 +59,114 @@ class TossPaymentsApiService
|
||||
/**
|
||||
* 결제 취소 API 호출
|
||||
*
|
||||
* @param string $paymentKey 토스 결제 키
|
||||
* @param string $cancelReason 취소 사유
|
||||
* @param int|null $cancelAmount 부분 취소 금액 (null이면 전액 취소)
|
||||
* 토스는 cancelAmount 를 넣지 않으면 전액 취소로 처리한다. 전액 취소를 부분 취소로
|
||||
* 보내지 않도록, 호출자가 전액이라고 판단한 경우 null 을 넘겨야 한다.
|
||||
*
|
||||
* @param string $paymentKey 토스 결제 키
|
||||
* @param string $cancelReason 취소 사유
|
||||
* @param int|null $cancelAmount 부분 취소 금액 (null이면 전액 취소)
|
||||
* @param int|null $taxFreeAmount 취소 금액 중 면세 금액 (복합과세 상점의 VAT 오산 방지)
|
||||
* @param array|null $refundReceiveAccount 가상계좌 환불 계좌 {bank, accountNumber, holderName}
|
||||
* @param string|null $idempotencyKey 멱등키 (네트워크 재시도 시 중복 취소 방지)
|
||||
* @return array PG 응답 데이터
|
||||
* @throws \Exception API 호출 실패 시
|
||||
*
|
||||
* @throws TossPaymentsApiException API 호출 실패 시
|
||||
*/
|
||||
public function cancelPayment(string $paymentKey, string $cancelReason, ?int $cancelAmount = null): array
|
||||
{
|
||||
public function cancelPayment(
|
||||
string $paymentKey,
|
||||
string $cancelReason,
|
||||
?int $cancelAmount = null,
|
||||
?int $taxFreeAmount = null,
|
||||
?array $refundReceiveAccount = null,
|
||||
?string $idempotencyKey = null,
|
||||
): array {
|
||||
$body = ['cancelReason' => $cancelReason];
|
||||
|
||||
if ($cancelAmount !== null) {
|
||||
$body['cancelAmount'] = $cancelAmount;
|
||||
}
|
||||
|
||||
if ($taxFreeAmount !== null) {
|
||||
$body['taxFreeAmount'] = $taxFreeAmount;
|
||||
}
|
||||
|
||||
if ($refundReceiveAccount !== null) {
|
||||
$body['refundReceiveAccount'] = $refundReceiveAccount;
|
||||
}
|
||||
|
||||
// 훅: 결제 취소 전 (본인인증 등 확장 지점)
|
||||
\App\Extension\HookManager::doAction('sirsoft-tosspayments.payment.before_cancel', $paymentKey, $cancelReason, $cancelAmount);
|
||||
HookManager::doAction('sirsoft-tosspayments.payment.before_cancel', $paymentKey, $cancelReason, $cancelAmount);
|
||||
|
||||
$response = $this->request('POST', "/v1/payments/{$paymentKey}/cancel", $body);
|
||||
$response = $this->request('POST', "/v1/payments/{$paymentKey}/cancel", $body, $idempotencyKey);
|
||||
|
||||
\App\Extension\HookManager::doAction('sirsoft-tosspayments.payment.after_cancel', $paymentKey, $response);
|
||||
HookManager::doAction('sirsoft-tosspayments.payment.after_cancel', $paymentKey, $response);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증 발급 API 호출
|
||||
*
|
||||
* @param array $data 발급 데이터 {amount, orderId, orderName, type, customerIdentityNumber, taxFreeAmount?}
|
||||
* @param string|null $idempotencyKey 멱등키 (네트워크 재시도 시 이중 발급 방지)
|
||||
* @return array PG 응답 데이터 (receiptKey, receiptUrl, issueNumber 등)
|
||||
*
|
||||
* @throws TossPaymentsApiException API 호출 실패 시
|
||||
*/
|
||||
public function issueCashReceipt(array $data, ?string $idempotencyKey = null): array
|
||||
{
|
||||
return $this->request('POST', '/v1/cash-receipts', $data, $idempotencyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증 취소 API 호출
|
||||
*
|
||||
* 본문에 amount 를 넣으면 부분취소가 되지만 의도적으로 넣지 않는다 — 전액취소 전용이다.
|
||||
* 이래야 타 프로바이더(팝빌·바로빌 등) 어댑터가 동일 시그니처를 만족한다.
|
||||
*
|
||||
* cancelReason 은 토스의 필수 파라미터다. 누락하면 400 INVALID_REQUEST
|
||||
* ("필수 파라미터가 누락되었습니다.") 로 거부된다.
|
||||
*
|
||||
* @param string $receiptKey 토스 영수증 키
|
||||
* @param string $cancelReason 취소 사유
|
||||
* @param string|null $idempotencyKey 멱등키 (네트워크 재시도 시 이중 취소 방지)
|
||||
* @return array PG 응답 데이터
|
||||
*
|
||||
* @throws TossPaymentsApiException API 호출 실패 시
|
||||
*/
|
||||
public function cancelCashReceipt(string $receiptKey, string $cancelReason, ?string $idempotencyKey = null): array
|
||||
{
|
||||
return $this->request('POST', "/v1/cash-receipts/{$receiptKey}/cancel", [
|
||||
'cancelReason' => $cancelReason,
|
||||
], $idempotencyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 API 요청
|
||||
*
|
||||
* @param string $method HTTP 메서드
|
||||
* @param string $path API 경로
|
||||
* @param array $body 요청 본문
|
||||
* @param string $method HTTP 메서드
|
||||
* @param string $path API 경로
|
||||
* @param array $body 요청 본문
|
||||
* @param string|null $idempotencyKey 멱등키 (지정 시 Idempotency-Key 헤더 부착)
|
||||
* @return array 응답 데이터
|
||||
* @throws \Exception API 호출 실패 시
|
||||
*
|
||||
* @throws TossPaymentsApiException API 호출 실패 시
|
||||
*/
|
||||
private function request(string $method, string $path, array $body): array
|
||||
private function request(string $method, string $path, array $body, ?string $idempotencyKey = null): array
|
||||
{
|
||||
$authHeader = 'Basic ' . base64_encode($this->secretKey . ':');
|
||||
$authHeader = 'Basic '.base64_encode($this->secretKey.':');
|
||||
|
||||
$response = Http::withHeaders([
|
||||
$headers = [
|
||||
'Authorization' => $authHeader,
|
||||
'Content-Type' => 'application/json',
|
||||
])->{strtolower($method)}(self::BASE_URL . $path, $body);
|
||||
];
|
||||
|
||||
if ($idempotencyKey !== null) {
|
||||
$headers['Idempotency-Key'] = $idempotencyKey;
|
||||
}
|
||||
|
||||
$response = Http::withHeaders($headers)
|
||||
->{strtolower($method)}(self::BASE_URL.$path, $body);
|
||||
|
||||
if ($response->failed()) {
|
||||
$error = $response->json();
|
||||
@@ -108,7 +178,7 @@ class TossPaymentsApiService
|
||||
'error_message' => $error['message'] ?? '',
|
||||
]);
|
||||
|
||||
throw new \Exception(
|
||||
throw new TossPaymentsApiException(
|
||||
$error['message'] ?? 'TossPayments API error',
|
||||
$response->status()
|
||||
);
|
||||
|
||||
+10
-1
@@ -61,13 +61,22 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
*
|
||||
* @param int $totalAmount 주문 총액
|
||||
*/
|
||||
/**
|
||||
* 주문번호 충돌 방지용 시퀀스.
|
||||
*
|
||||
* random_int 로 만들면 같은 스위트 내 다른 테스트가 만든 주문과 번호가 겹칠 수 있고,
|
||||
* 그때 콜백이 엉뚱한 주문을 찾아 금액 불일치(amount_mismatch)로 실패한다 —
|
||||
* 낮은 확률로만 터지므로 "간헐적 실패" 로 나타나 원인을 오해하기 쉽다.
|
||||
*/
|
||||
private static int $orderSequence = 0;
|
||||
|
||||
private function createTestOrder(int $totalAmount = 50000): Order
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$order = OrderFactory::new()->create([
|
||||
'user_id' => $user->id,
|
||||
'order_number' => 'ORD-TEST-'.random_int(10000, 99999),
|
||||
'order_number' => 'ORD-TEST-'.(++self::$orderSequence),
|
||||
'order_status' => OrderStatusEnum::PENDING_ORDER,
|
||||
'subtotal_amount' => $totalAmount,
|
||||
'total_discount_amount' => 0,
|
||||
|
||||
+411
-46
@@ -2,16 +2,71 @@
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Listeners;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\RefundStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderRefund;
|
||||
use Plugins\Sirsoft\Tosspayments\Listeners\PaymentRefundListener;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* PaymentRefundListener 단위 테스트
|
||||
*
|
||||
* 환불 정합성 3건(R1 전액취소 / R2 면세금액 / R3 가상계좌 환불계좌) +
|
||||
* 에스크로 부분취소 사전 차단(E4) + 멱등키를 검증한다.
|
||||
*/
|
||||
class PaymentRefundListenerTest extends TestCase
|
||||
class PaymentRefundListenerTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* 토스 취소 API 를 성공 응답으로 가짜 응답 처리합니다.
|
||||
*/
|
||||
private function fakeCancelApi(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.tosspayments.com/*' => Http::response([
|
||||
'cancels' => [['transactionKey' => 'TXN_CANCEL_1']],
|
||||
], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스 결제가 달린 주문을 생성합니다.
|
||||
*
|
||||
* @param array<string, mixed> $paymentAttributes 결제 레코드 덮어쓸 속성
|
||||
*/
|
||||
private function createTossOrder(array $paymentAttributes = []): Order
|
||||
{
|
||||
$order = Order::factory()->create();
|
||||
|
||||
OrderPayment::factory()->create(array_merge([
|
||||
'order_id' => $order->id,
|
||||
'pg_provider' => 'tosspayments',
|
||||
'transaction_id' => 'test_payment_key',
|
||||
'paid_amount_local' => 10000,
|
||||
'cancelled_amount' => 0,
|
||||
'currency' => 'KRW',
|
||||
'is_escrow' => false,
|
||||
], $paymentAttributes));
|
||||
|
||||
return $order->fresh(['payment']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 환불 레코드를 생성합니다. (OrderRefundFactory 부재 — 직접 생성)
|
||||
*/
|
||||
private function createRefund(Order $order, float $amount): OrderRefund
|
||||
{
|
||||
return OrderRefund::create([
|
||||
'order_id' => $order->id,
|
||||
'refund_number' => 'RF'.uniqid(),
|
||||
'refund_status' => RefundStatusEnum::REQUESTED,
|
||||
'refund_method' => RefundMethodEnum::PG,
|
||||
'refund_amount' => $amount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* getSubscribedHooks가 올바른 훅 매핑을 반환하는지 확인
|
||||
*/
|
||||
@@ -30,23 +85,17 @@ class PaymentRefundListenerTest extends TestCase
|
||||
*/
|
||||
public function test_process_refund_skips_non_tosspayments_provider(): void
|
||||
{
|
||||
$listener = new PaymentRefundListener();
|
||||
Http::fake();
|
||||
$order = $this->createTossOrder(['pg_provider' => 'other_pg']);
|
||||
$default = ['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null];
|
||||
|
||||
$order = $this->createMock(Order::class);
|
||||
$payment = $this->createStub(OrderPayment::class);
|
||||
$payment->method('__get')->willReturnCallback(function ($key) {
|
||||
return match ($key) {
|
||||
'pg_provider' => 'other_pg',
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
|
||||
$defaultResult = ['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null];
|
||||
|
||||
$result = $listener->processRefund($defaultResult, $order, $payment, 10000.0);
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
$default, $order, $order->payment, 10000.0, null, $this->createRefund($order, 10000)
|
||||
);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertNull($result['error_code']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,49 +104,365 @@ class PaymentRefundListenerTest extends TestCase
|
||||
*/
|
||||
public function test_process_refund_does_not_match_plugin_identifier(): void
|
||||
{
|
||||
$listener = new PaymentRefundListener();
|
||||
Http::fake();
|
||||
$order = $this->createTossOrder(['pg_provider' => 'sirsoft-tosspayments']);
|
||||
$default = ['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null];
|
||||
|
||||
$order = $this->createMock(Order::class);
|
||||
$payment = $this->createStub(OrderPayment::class);
|
||||
$payment->method('__get')->willReturnCallback(function ($key) {
|
||||
return match ($key) {
|
||||
'pg_provider' => 'sirsoft-tosspayments',
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
$default, $order, $order->payment, 10000.0, null, $this->createRefund($order, 10000)
|
||||
);
|
||||
|
||||
$defaultResult = ['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null];
|
||||
|
||||
$result = $listener->processRefund($defaultResult, $order, $payment, 10000.0);
|
||||
|
||||
// 플러그인 식별자는 PG provider ID가 아니므로 스킵되어야 함
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertNull($result['error_code']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
/**
|
||||
* pg_provider가 'tosspayments'일 때 스킵하지 않고 처리에 진입하는지 확인
|
||||
* 결제 키가 없으면 API 를 호출하지 않고 실패를 반환하는지 확인
|
||||
*/
|
||||
public function test_process_refund_enters_processing_for_tosspayments(): void
|
||||
public function test_process_refund_fails_without_payment_key(): void
|
||||
{
|
||||
$listener = new PaymentRefundListener();
|
||||
Http::fake();
|
||||
$order = $this->createTossOrder(['transaction_id' => null]);
|
||||
$default = ['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null];
|
||||
|
||||
$order = $this->createMock(Order::class);
|
||||
$payment = $this->createStub(OrderPayment::class);
|
||||
$payment->method('__get')->willReturnCallback(function ($key) {
|
||||
return match ($key) {
|
||||
'pg_provider' => 'tosspayments',
|
||||
'transaction_id' => 'test_payment_key',
|
||||
default => null,
|
||||
};
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
$default, $order, $order->payment, 10000.0, null, $this->createRefund($order, 10000)
|
||||
);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('MISSING_PAYMENT_KEY', $result['error_code']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// R1. 전액 환불은 cancelAmount 를 보내지 않는다 (토스: 미지정 = 전액취소)
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* R1: 전액 환불 시 cancelAmount 를 전달하지 않는지 확인
|
||||
*
|
||||
* 현행 결함: 전액이어도 cancelAmount 를 항상 넣어 부분취소로 오처리된다.
|
||||
*
|
||||
* @effects full_refund_omits_cancel_amount
|
||||
*/
|
||||
public function test_r1_full_refund_omits_cancel_amount(): void
|
||||
{
|
||||
$this->fakeCancelApi();
|
||||
$order = $this->createTossOrder(['paid_amount_local' => 10000, 'cancelled_amount' => 0]);
|
||||
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 10000.0, null, $this->createRefund($order, 10000)
|
||||
);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
Http::assertSent(function ($request) {
|
||||
return ! array_key_exists('cancelAmount', $request->data());
|
||||
});
|
||||
}
|
||||
|
||||
$defaultResult = ['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null];
|
||||
/**
|
||||
* R1: 부분 환불 시에는 cancelAmount 를 전달하는지 확인
|
||||
*
|
||||
* @effects partial_refund_sends_cancel_amount
|
||||
*/
|
||||
public function test_r1_partial_refund_sends_cancel_amount(): void
|
||||
{
|
||||
$this->fakeCancelApi();
|
||||
$order = $this->createTossOrder(['paid_amount_local' => 10000, 'cancelled_amount' => 0]);
|
||||
|
||||
// getApiService()에서 app() 호출 시 Laravel 컨테이너 미준비로
|
||||
// BindingResolutionException 발생 → 정상적으로 tosspayments 분기로 진입했다는 증거
|
||||
$this->expectException(\Throwable::class);
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 3000.0, null, $this->createRefund($order, 3000)
|
||||
);
|
||||
|
||||
$listener->processRefund($defaultResult, $order, $payment, 10000.0);
|
||||
$this->assertTrue($result['success']);
|
||||
Http::assertSent(fn ($request) => $request->data()['cancelAmount'] === 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* R1: 이미 부분취소한 이력이 있으면 잔액 전부를 취소해도 부분취소로 전달하는지 확인
|
||||
*
|
||||
* 토스는 cancelAmount 미지정 시 "결제 전액" 을 취소하므로, 잔액 취소를 전액취소로
|
||||
* 보내면 이미 취소된 금액을 두 번 취소하게 된다.
|
||||
*
|
||||
* @effects remaining_refund_after_partial_stays_partial
|
||||
*/
|
||||
public function test_r1_remaining_refund_after_partial_stays_partial(): void
|
||||
{
|
||||
$this->fakeCancelApi();
|
||||
// 10000 결제 중 3000 이 이미 취소됨 → 잔액 7000 취소
|
||||
$order = $this->createTossOrder([
|
||||
'paid_amount_local' => 10000,
|
||||
'cancelled_amount' => 7000,
|
||||
'mc_cancelled_amount' => ['KRW' => 7000],
|
||||
]);
|
||||
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 7000.0, null, $this->createRefund($order, 7000)
|
||||
);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
Http::assertSent(fn ($request) => ($request->data()['cancelAmount'] ?? null) === 7000);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// R2. taxFreeAmount 동반 전달 (복합과세 상점의 VAT 오산 방지)
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* R2: 면세 금액이 있으면 환불액 비율만큼 안분해 taxFreeAmount 를 동반 전달하는지 확인
|
||||
*
|
||||
* 면세/과세 분류의 SSoT 는 주문(total_tax_free_amount / total_tax_amount)이다.
|
||||
* 결제 레코드에는 면세 컬럼이 없다.
|
||||
*
|
||||
* @effects refund_sends_apportioned_tax_free_amount
|
||||
*/
|
||||
public function test_r2_sends_apportioned_tax_free_amount_when_present(): void
|
||||
{
|
||||
$this->fakeCancelApi();
|
||||
$order = $this->createTossOrder(['paid_amount_local' => 10000]);
|
||||
$order->update([
|
||||
'total_tax_free_amount' => 4000,
|
||||
'total_tax_amount' => 6000,
|
||||
]);
|
||||
$order = $order->fresh(['payment']);
|
||||
|
||||
(new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 5000.0, null, $this->createRefund($order, 5000)
|
||||
);
|
||||
|
||||
// 환불 비율(5000/10000)만큼 면세분 안분: 4000 * 0.5 = 2000
|
||||
Http::assertSent(fn ($request) => ($request->data()['taxFreeAmount'] ?? null) === 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* R2: 전액 환불 시 면세분 전체를 taxFreeAmount 로 전달하는지 확인
|
||||
*/
|
||||
public function test_r2_full_refund_sends_entire_tax_free_amount(): void
|
||||
{
|
||||
$this->fakeCancelApi();
|
||||
$order = $this->createTossOrder(['paid_amount_local' => 10000]);
|
||||
$order->update(['total_tax_free_amount' => 4000, 'total_tax_amount' => 6000]);
|
||||
$order = $order->fresh(['payment']);
|
||||
|
||||
(new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 10000.0, null, $this->createRefund($order, 10000)
|
||||
);
|
||||
|
||||
Http::assertSent(fn ($request) => ($request->data()['taxFreeAmount'] ?? null) === 4000);
|
||||
}
|
||||
|
||||
/**
|
||||
* R2: 면세 금액이 없으면(전액 과세) taxFreeAmount 를 전달하지 않는지 확인
|
||||
*/
|
||||
public function test_r2_omits_tax_free_amount_when_zero(): void
|
||||
{
|
||||
$this->fakeCancelApi();
|
||||
$order = $this->createTossOrder(['paid_amount_local' => 10000]);
|
||||
$order->update(['total_tax_free_amount' => 0, 'total_tax_amount' => 10000]);
|
||||
$order = $order->fresh(['payment']);
|
||||
|
||||
(new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 5000.0, null, $this->createRefund($order, 5000)
|
||||
);
|
||||
|
||||
Http::assertSent(fn ($request) => ! array_key_exists('taxFreeAmount', $request->data()));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// R3. 가상계좌 환불은 refundReceiveAccount 가 필수
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* R3: 가상계좌 결제 환불 시 환불계좌를 전달하는지 확인
|
||||
*
|
||||
* @effects vbank_refund_sends_refund_receive_account
|
||||
*/
|
||||
public function test_r3_virtual_account_refund_sends_receive_account(): void
|
||||
{
|
||||
$this->fakeCancelApi();
|
||||
$order = $this->createTossOrder([
|
||||
'payment_method' => 'vbank',
|
||||
'refund_bank_code' => '20',
|
||||
'refund_bank_account' => '1234567890',
|
||||
'refund_bank_holder' => '홍길동',
|
||||
]);
|
||||
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 10000.0, null, $this->createRefund($order, 10000)
|
||||
);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
Http::assertSent(function ($request) {
|
||||
$account = $request->data()['refundReceiveAccount'] ?? null;
|
||||
|
||||
return $account === [
|
||||
'bank' => '20',
|
||||
'accountNumber' => '1234567890',
|
||||
'holderName' => '홍길동',
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* R3: 가상계좌인데 환불계좌가 없으면 API 호출 없이 실패하는지 확인
|
||||
*
|
||||
* @effects vbank_refund_without_account_fails_before_api_call
|
||||
*/
|
||||
public function test_r3_virtual_account_without_account_fails_before_api_call(): void
|
||||
{
|
||||
Http::fake();
|
||||
$order = $this->createTossOrder([
|
||||
'payment_method' => 'vbank',
|
||||
'refund_bank_code' => null,
|
||||
'refund_bank_account' => null,
|
||||
'refund_bank_holder' => null,
|
||||
]);
|
||||
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 10000.0, null, $this->createRefund($order, 10000)
|
||||
);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('MISSING_REFUND_ACCOUNT', $result['error_code']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
/**
|
||||
* R3: 가상계좌가 아니면 환불계좌 없이도 정상 취소되는지 확인
|
||||
*/
|
||||
public function test_r3_card_refund_does_not_require_receive_account(): void
|
||||
{
|
||||
$this->fakeCancelApi();
|
||||
$order = $this->createTossOrder(['payment_method' => 'card']);
|
||||
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 10000.0, null, $this->createRefund($order, 10000)
|
||||
);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
Http::assertSent(fn ($request) => ! array_key_exists('refundReceiveAccount', $request->data()));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// E4. 에스크로 주문의 부분취소는 API 호출 전에 차단한다
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* E4: 에스크로 주문의 부분취소는 API 호출 없이 차단되는지 확인
|
||||
*
|
||||
* @effects escrow_partial_refund_blocked_with_zero_api_calls
|
||||
*/
|
||||
public function test_e4_escrow_partial_refund_blocked_before_api_call(): void
|
||||
{
|
||||
Http::fake();
|
||||
$order = $this->createTossOrder(['is_escrow' => true, 'paid_amount_local' => 10000]);
|
||||
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 3000.0, null, $this->createRefund($order, 3000)
|
||||
);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('ESCROW_PARTIAL_REFUND_NOT_ALLOWED', $result['error_code']);
|
||||
$this->assertNotEmpty($result['error_message']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
/**
|
||||
* E4: 에스크로 주문이라도 전액취소는 허용되는지 확인
|
||||
*
|
||||
* @effects escrow_full_refund_allowed
|
||||
*/
|
||||
public function test_e4_escrow_full_refund_is_allowed(): void
|
||||
{
|
||||
$this->fakeCancelApi();
|
||||
$order = $this->createTossOrder(['is_escrow' => true, 'paid_amount_local' => 10000]);
|
||||
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 10000.0, null, $this->createRefund($order, 10000)
|
||||
);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
Http::assertSent(fn ($request) => ! array_key_exists('cancelAmount', $request->data()));
|
||||
}
|
||||
|
||||
/**
|
||||
* E4: 결제 시점 스냅샷(is_escrow)이 기준이므로, 플러그인 설정을 off 로 바꿔도
|
||||
* 기존 에스크로 주문의 부분취소는 계속 차단되는지 확인
|
||||
*
|
||||
* @effects escrow_blocking_uses_payment_snapshot_not_current_setting
|
||||
*/
|
||||
public function test_e4_blocking_is_based_on_payment_snapshot_not_current_setting(): void
|
||||
{
|
||||
Http::fake();
|
||||
// 현재 설정은 에스크로 미사용 — 그럼에도 결제 시점 스냅샷이 기준이어야 한다.
|
||||
config(['plugins.sirsoft-tosspayments.use_escrow' => 'off']);
|
||||
|
||||
$order = $this->createTossOrder(['is_escrow' => true, 'paid_amount_local' => 10000]);
|
||||
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 3000.0, null, $this->createRefund($order, 3000)
|
||||
);
|
||||
|
||||
$this->assertEquals('ESCROW_PARTIAL_REFUND_NOT_ALLOWED', $result['error_code']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 멱등키 (Idempotency-Key)
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 멱등키: 취소 요청에 환불 레코드 기반 Idempotency-Key 헤더를 부착하는지 확인
|
||||
*
|
||||
* @effects cancel_request_carries_idempotency_key_from_refund_record
|
||||
*/
|
||||
public function test_attaches_idempotency_key_header_from_refund_record(): void
|
||||
{
|
||||
$this->fakeCancelApi();
|
||||
$order = $this->createTossOrder();
|
||||
$refund = $this->createRefund($order, 10000);
|
||||
|
||||
(new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 10000.0, null, $refund
|
||||
);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('Idempotency-Key')
|
||||
&& str_contains($request->header('Idempotency-Key')[0], (string) $refund->id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 멱등키: 환불 레코드가 없으면(레거시 호출) 헤더 없이도 취소가 동작하는지 확인
|
||||
*
|
||||
* @effects cancel_without_refund_record_still_succeeds
|
||||
*/
|
||||
public function test_refund_without_record_still_cancels_without_idempotency_key(): void
|
||||
{
|
||||
$this->fakeCancelApi();
|
||||
$order = $this->createTossOrder();
|
||||
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 10000.0, null
|
||||
);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
Http::assertSent(fn ($request) => ! $request->hasHeader('Idempotency-Key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* API 오류 시 실패 결과를 반환하는지 확인
|
||||
*/
|
||||
public function test_process_refund_returns_failure_on_api_error(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.tosspayments.com/*' => Http::response(['message' => '취소 불가'], 400),
|
||||
]);
|
||||
$order = $this->createTossOrder();
|
||||
|
||||
$result = (new PaymentRefundListener)->processRefund(
|
||||
['success' => false], $order, $order->payment, 10000.0, null, $this->createRefund($order, 10000)
|
||||
);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('PG_API_ERROR', $result['error_code']);
|
||||
}
|
||||
}
|
||||
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Listeners;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Plugins\Sirsoft\Tosspayments\Listeners\RegisterCashReceiptProviderListener;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* RegisterCashReceiptProviderListener 단위 테스트
|
||||
*
|
||||
* 현금영수증 프로바이더 등록 + 발급/취소 위임 + orderId 회차 접미를 검증한다.
|
||||
*/
|
||||
class RegisterCashReceiptProviderListenerTest extends PluginTestCase
|
||||
{
|
||||
private const PROVIDER_ID = 'tosspayments';
|
||||
|
||||
/**
|
||||
* 발급 훅의 기본 반환값 (코어 CashReceiptService::defaultIssueResult 와 동형)
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultIssueResult(): array
|
||||
{
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => 'NO_PROVIDER_HANDLED',
|
||||
'error_message' => 'no provider',
|
||||
'receipt_key' => null,
|
||||
'receipt_url' => null,
|
||||
'issue_number' => null,
|
||||
'raw_response' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 훅의 기본 반환값
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultCancelResult(): array
|
||||
{
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => 'NO_PROVIDER_HANDLED',
|
||||
'error_message' => 'no provider',
|
||||
'receipt_key' => null,
|
||||
'raw_response' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어가 전달하는 발급 페이로드를 생성합니다.
|
||||
*
|
||||
* @param array<string, mixed> $overrides 덮어쓸 키
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function issuePayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'type' => 'income',
|
||||
'identifier' => '01012345678',
|
||||
'identifier_type' => 'phone',
|
||||
'amount' => 10000,
|
||||
'tax_free_amount' => 0,
|
||||
'order_name' => '테스트 상품',
|
||||
'order_number' => 'ORD20260712001',
|
||||
'issue_sequence' => 1,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스 현금영수증 발급 API 를 성공 응답으로 가짜 처리합니다.
|
||||
*/
|
||||
private function fakeIssueApi(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.tosspayments.com/v1/cash-receipts' => Http::response([
|
||||
'receiptKey' => 'RK_123',
|
||||
'receiptUrl' => 'https://dashboard.tosspayments.com/receipt/RK_123',
|
||||
'issueNumber' => 'ISSUE_999',
|
||||
], 200),
|
||||
'api.tosspayments.com/*' => Http::response([], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구독 훅 매핑이 3개 훅을 filter 로 선언하는지 확인
|
||||
*/
|
||||
public function test_get_subscribed_hooks_declares_three_filter_hooks(): void
|
||||
{
|
||||
$hooks = RegisterCashReceiptProviderListener::getSubscribedHooks();
|
||||
|
||||
foreach ([
|
||||
'sirsoft-ecommerce.cash_receipt.registered_providers',
|
||||
'sirsoft-ecommerce.cash_receipt.issue',
|
||||
'sirsoft-ecommerce.cash_receipt.cancel',
|
||||
] as $hook) {
|
||||
$this->assertArrayHasKey($hook, $hooks);
|
||||
$this->assertEquals('filter', $hooks[$hook]['type'], "{$hook} 은 filter 여야 합니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로바이더 등록: 기존 목록을 덮어쓰지 않고 자기 항목을 추가하는지 확인
|
||||
*
|
||||
* @effects provider_registered_without_overwriting_others
|
||||
*/
|
||||
public function test_register_provider_appends_without_overwriting(): void
|
||||
{
|
||||
$existing = [['id' => 'kginicis', 'name' => 'KG이니시스']];
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->registerProvider($existing);
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
$this->assertEquals('kginicis', $result[0]['id']);
|
||||
$this->assertEquals(self::PROVIDER_ID, $result[1]['id']);
|
||||
$this->assertNotEmpty($result[1]['name_key']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급: 타 프로바이더 요청은 건드리지 않고 그대로 통과시키는지 확인
|
||||
*
|
||||
* @effects other_provider_request_passes_through_untouched
|
||||
*/
|
||||
public function test_issue_early_returns_for_other_provider(): void
|
||||
{
|
||||
Http::fake();
|
||||
$order = Order::factory()->create();
|
||||
$default = $this->defaultIssueResult();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->issue($default, $order, 'kginicis', $this->issuePayload());
|
||||
|
||||
$this->assertSame($default, $result);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급: 성공 시 코어가 기대하는 키(receipt_key/receipt_url/issue_number)를 반환하는지 확인
|
||||
*
|
||||
* @effects issue_response_mapped_to_receipt_key_and_issue_number
|
||||
*/
|
||||
public function test_issue_returns_receipt_fields_on_success(): void
|
||||
{
|
||||
$this->fakeIssueApi();
|
||||
$order = Order::factory()->create();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->issue(
|
||||
$this->defaultIssueResult(), $order, self::PROVIDER_ID, $this->issuePayload()
|
||||
);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('RK_123', $result['receipt_key']);
|
||||
$this->assertEquals('https://dashboard.tosspayments.com/receipt/RK_123', $result['receipt_url']);
|
||||
$this->assertEquals('ISSUE_999', $result['issue_number']);
|
||||
$this->assertNull($result['error_code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급: 코어 enum(income/expense) 을 토스가 요구하는 한글 type 으로 변환하는지 확인
|
||||
*
|
||||
* 프로바이더 종속 표현(한글)은 리스너 내부에서만 다룬다.
|
||||
*
|
||||
* @effects receipt_type_converted_to_provider_specific_value
|
||||
*/
|
||||
public function test_issue_converts_type_to_korean_for_toss(): void
|
||||
{
|
||||
$this->fakeIssueApi();
|
||||
$order = Order::factory()->create();
|
||||
$listener = new RegisterCashReceiptProviderListener;
|
||||
|
||||
$listener->issue($this->defaultIssueResult(), $order, self::PROVIDER_ID, $this->issuePayload(['type' => 'income']));
|
||||
Http::assertSent(fn ($request) => ($request->data()['type'] ?? null) === '소득공제');
|
||||
|
||||
Http::fake([
|
||||
'api.tosspayments.com/v1/cash-receipts' => Http::response(['receiptKey' => 'RK_2'], 200),
|
||||
]);
|
||||
$listener->issue($this->defaultIssueResult(), $order, self::PROVIDER_ID, $this->issuePayload(['type' => 'expense']));
|
||||
Http::assertSent(fn ($request) => ($request->data()['type'] ?? null) === '지출증빙');
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급: orderId 에 발급 회차를 접미하는지 확인 (재발급 시 중복 거부 회피)
|
||||
*
|
||||
* @effects order_id_suffixed_with_issue_sequence
|
||||
*/
|
||||
public function test_issue_appends_sequence_suffix_to_order_id(): void
|
||||
{
|
||||
$this->fakeIssueApi();
|
||||
$order = Order::factory()->create();
|
||||
|
||||
(new RegisterCashReceiptProviderListener)->issue(
|
||||
$this->defaultIssueResult(),
|
||||
$order,
|
||||
self::PROVIDER_ID,
|
||||
$this->issuePayload(['order_number' => 'ORD20260712001', 'issue_sequence' => 3])
|
||||
);
|
||||
|
||||
Http::assertSent(fn ($request) => ($request->data()['orderId'] ?? null) === 'ORD20260712001-cr3');
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급: orderId 가 토스 제약(6~64자, 영숫자/-/_)을 벗어나면 API 호출 전에 실패하는지 확인
|
||||
*
|
||||
* @effects order_id_constraint_violation_blocked_before_api_call
|
||||
*/
|
||||
public function test_issue_rejects_order_id_violating_toss_constraints(): void
|
||||
{
|
||||
Http::fake();
|
||||
$order = Order::factory()->create();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->issue(
|
||||
$this->defaultIssueResult(),
|
||||
$order,
|
||||
self::PROVIDER_ID,
|
||||
// 허용되지 않는 문자(한글) 포함
|
||||
$this->issuePayload(['order_number' => '주문번호!!'])
|
||||
);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('INVALID_ORDER_ID', $result['error_code']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급: 면세 금액이 있으면 taxFreeAmount 를 동반 전달하는지 확인
|
||||
*
|
||||
* @effects tax_free_amount_sent_with_issue
|
||||
*/
|
||||
public function test_issue_sends_tax_free_amount(): void
|
||||
{
|
||||
$this->fakeIssueApi();
|
||||
$order = Order::factory()->create();
|
||||
|
||||
(new RegisterCashReceiptProviderListener)->issue(
|
||||
$this->defaultIssueResult(),
|
||||
$order,
|
||||
self::PROVIDER_ID,
|
||||
$this->issuePayload(['amount' => 10000, 'tax_free_amount' => 3000])
|
||||
);
|
||||
|
||||
Http::assertSent(fn ($request) => ($request->data()['taxFreeAmount'] ?? null) === 3000
|
||||
&& ($request->data()['amount'] ?? null) === 10000
|
||||
&& ($request->data()['customerIdentityNumber'] ?? null) === '01012345678');
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급: API 오류 시 실패 결과를 반환하는지 확인 (예외를 밖으로 던지지 않는다)
|
||||
*
|
||||
* @effects issue_failure_returns_result_without_throwing
|
||||
*/
|
||||
public function test_issue_returns_failure_on_api_error(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.tosspayments.com/*' => Http::response(['message' => '발급 불가'], 400),
|
||||
]);
|
||||
$order = Order::factory()->create();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->issue(
|
||||
$this->defaultIssueResult(), $order, self::PROVIDER_ID, $this->issuePayload()
|
||||
);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('PG_API_ERROR', $result['error_code']);
|
||||
$this->assertNotEmpty($result['error_message']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급: 멱등키(Idempotency-Key)를 부착하는지 확인
|
||||
*
|
||||
* 네트워크 재시도로 같은 발급 요청이 두 번 도달하면 현금영수증이 이중 발급되고
|
||||
* 국세청 신고가 틀어진다. 발급 회차가 포함된 orderId 는 재시도 간에 안정적이므로
|
||||
* 그대로 멱등키로 쓴다.
|
||||
*
|
||||
* @effects issue_request_carries_idempotency_key
|
||||
*/
|
||||
public function test_issue_sends_idempotency_key(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.tosspayments.com/*' => Http::response(['receiptKey' => 'RK', 'issueNumber' => '1'], 200),
|
||||
]);
|
||||
$order = Order::factory()->create();
|
||||
|
||||
(new RegisterCashReceiptProviderListener)->issue(
|
||||
$this->defaultIssueResult(), $order, self::PROVIDER_ID,
|
||||
$this->issuePayload(['order_number' => 'ORD-20260712', 'issue_sequence' => 2])
|
||||
);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$key = $request->header('Idempotency-Key')[0] ?? null;
|
||||
|
||||
// 발급 회차가 반영된 orderId 와 동일해야 재시도 간 안정적이다.
|
||||
return $key === 'ORD-20260712-cr2';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소: 멱등키(Idempotency-Key)를 부착하는지 확인
|
||||
*
|
||||
* 재시도로 취소가 두 번 도달하면 이미 취소된 영수증을 다시 취소하려 해 실패하거나
|
||||
* 이력이 어긋난다. 취소 대상 영수증 키는 재시도 간에 안정적이다.
|
||||
*
|
||||
* @effects cancel_request_carries_idempotency_key
|
||||
*/
|
||||
public function test_cancel_sends_idempotency_key(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.tosspayments.com/*' => Http::response(['receiptKey' => 'c_RK_123'], 200),
|
||||
]);
|
||||
$order = Order::factory()->create();
|
||||
|
||||
(new RegisterCashReceiptProviderListener)->cancel(
|
||||
$this->defaultCancelResult(), $order, self::PROVIDER_ID, 'RK_123'
|
||||
);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$key = $request->header('Idempotency-Key')[0] ?? null;
|
||||
|
||||
return $key === 'cr-cancel-RK_123';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소: 타 프로바이더 요청은 그대로 통과시키는지 확인
|
||||
*
|
||||
* @effects other_provider_request_passes_through_untouched
|
||||
*/
|
||||
public function test_cancel_early_returns_for_other_provider(): void
|
||||
{
|
||||
Http::fake();
|
||||
$order = Order::factory()->create();
|
||||
$default = $this->defaultCancelResult();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->cancel($default, $order, 'kginicis', 'RK_123');
|
||||
|
||||
$this->assertSame($default, $result);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소: 전액취소로 위임하는지 확인 (본문에 amount 를 넣지 않는다 — D7)
|
||||
*
|
||||
* cancelReason 은 토스가 요구하는 필수 파라미터다. 빠뜨리면 실서버가
|
||||
* 400 INVALID_REQUEST("필수 파라미터가 누락되었습니다.") 로 거부한다.
|
||||
*
|
||||
* @effects cancel_delegated_as_full_cancel_without_amount
|
||||
* @effects cancel_sends_required_cancel_reason
|
||||
*/
|
||||
public function test_cancel_delegates_as_full_cancel_without_amount(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.tosspayments.com/*' => Http::response(['receiptKey' => 'RK_123'], 200),
|
||||
]);
|
||||
$order = Order::factory()->create();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->cancel(
|
||||
$this->defaultCancelResult(), $order, self::PROVIDER_ID, 'RK_123'
|
||||
);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
Http::assertSent(function ($request) {
|
||||
$data = $request->data();
|
||||
|
||||
return str_contains($request->url(), '/v1/cash-receipts/RK_123/cancel')
|
||||
&& ! array_key_exists('amount', $data)
|
||||
&& ! empty($data['cancelReason']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소: 취소 결과의 receipt_key 가 "취소된 영수증"의 키여야 하는지 확인
|
||||
*
|
||||
* 토스는 취소 응답으로 'c_' 접두 키(취소 거래 자체의 키)를 돌려준다. 그 값을 그대로
|
||||
* 저장하면 코어의 OrderCashReceipt::filterActive() 가 취소 행을 발급 행과 매칭하지
|
||||
* 못해(키가 달라서) 취소된 영수증이 영원히 "활성"으로 남고, 금액 변동 시 재발급이
|
||||
* 일어나지 않는다.
|
||||
*
|
||||
* @effects cancel_uses_issue_tid_as_receipt_key
|
||||
*/
|
||||
public function test_cancel_returns_cancelled_receipt_key_not_cancel_transaction_key(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.tosspayments.com/*' => Http::response(['receiptKey' => 'c_RK_123'], 200),
|
||||
]);
|
||||
$order = Order::factory()->create();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->cancel(
|
||||
$this->defaultCancelResult(), $order, self::PROVIDER_ID, 'RK_123'
|
||||
);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertSame(
|
||||
'RK_123',
|
||||
$result['receipt_key'],
|
||||
'취소 행은 취소된 영수증의 키를 보관해야 합니다 (취소 거래 키 c_* 가 아니라).'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소: API 오류 시 실패 결과를 반환하는지 확인
|
||||
*/
|
||||
public function test_cancel_returns_failure_on_api_error(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.tosspayments.com/*' => Http::response(['message' => '취소 불가'], 400),
|
||||
]);
|
||||
$order = Order::factory()->create();
|
||||
|
||||
$result = (new RegisterCashReceiptProviderListener)->cancel(
|
||||
$this->defaultCancelResult(), $order, self::PROVIDER_ID, 'RK_123'
|
||||
);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('PG_API_ERROR', $result['error_code']);
|
||||
}
|
||||
}
|
||||
+75
-4
@@ -3,19 +3,19 @@
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Services;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Plugins\Sirsoft\Tosspayments\Services\TossPaymentsApiService;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* TossPaymentsApiService 단위 테스트
|
||||
*/
|
||||
class TossPaymentsApiServiceTest extends TestCase
|
||||
class TossPaymentsApiServiceTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* PluginSettingsService mock을 생성합니다.
|
||||
*
|
||||
* @param array $settings 반환할 설정값
|
||||
* @return PluginSettingsService
|
||||
* @param array $settings 반환할 설정값
|
||||
*/
|
||||
private function mockSettingsService(array $settings): PluginSettingsService
|
||||
{
|
||||
@@ -95,4 +95,75 @@ class TossPaymentsApiServiceTest extends TestCase
|
||||
|
||||
$this->assertEquals('', $prop->getValue($service));
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증 API 호출용 서비스를 만듭니다.
|
||||
*/
|
||||
private function makeService(): TossPaymentsApiService
|
||||
{
|
||||
return new TossPaymentsApiService($this->mockSettingsService([
|
||||
'test_mode' => true,
|
||||
'test_secret_key' => 'test_sk_dummy',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급은 토스 현금영수증 엔드포인트로 요청 본문을 그대로 보내야 합니다.
|
||||
*/
|
||||
public function test_issue_cash_receipt_posts_to_cash_receipts_endpoint(): void
|
||||
{
|
||||
Http::fake(['*' => Http::response(['receiptKey' => 'RK_1', 'issueNumber' => '123'], 200)]);
|
||||
|
||||
$this->makeService()->issueCashReceipt(['orderId' => 'ORD-1', 'amount' => 1000]);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://api.tosspayments.com/v1/cash-receipts'
|
||||
&& $request->method() === 'POST'
|
||||
&& $request->data()['orderId'] === 'ORD-1';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소는 cancelReason 을 반드시 실어야 합니다.
|
||||
*
|
||||
* 토스의 필수 파라미터이며, 누락 시 400 INVALID_REQUEST 로 거부된다.
|
||||
* Http::fake 는 본문을 검증하지 않으므로 "보냈다" 가 아니라 "필수 필드가 들어있다" 를 단언한다.
|
||||
*/
|
||||
public function test_cancel_cash_receipt_sends_cancel_reason(): void
|
||||
{
|
||||
Http::fake(['*' => Http::response(['receiptKey' => 'c_RK_1'], 200)]);
|
||||
|
||||
$this->makeService()->cancelCashReceipt('RK_1', '금액 변경');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://api.tosspayments.com/v1/cash-receipts/RK_1/cancel'
|
||||
&& ! empty($request->data()['cancelReason']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 멱등키를 넘기면 Idempotency-Key 헤더로 전송되어야 합니다.
|
||||
*
|
||||
* 이 헤더가 없으면 네트워크 재시도가 국세청에 이중 신고된다.
|
||||
*/
|
||||
public function test_idempotency_key_is_sent_as_header(): void
|
||||
{
|
||||
Http::fake(['*' => Http::response(['receiptKey' => 'RK_1'], 200)]);
|
||||
|
||||
$this->makeService()->issueCashReceipt(['orderId' => 'ORD-1'], 'ORD-1-cr1');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('Idempotency-Key', 'ORD-1-cr1'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 멱등키를 넘기지 않으면 Idempotency-Key 헤더가 붙지 않아야 합니다.
|
||||
*/
|
||||
public function test_idempotency_key_header_absent_when_not_provided(): void
|
||||
{
|
||||
Http::fake(['*' => Http::response(['receiptKey' => 'RK_1'], 200)]);
|
||||
|
||||
$this->makeService()->issueCashReceipt(['orderId' => 'ORD-1']);
|
||||
|
||||
Http::assertSent(fn ($request) => ! $request->hasHeader('Idempotency-Key'));
|
||||
}
|
||||
}
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# audit:allow test-scenario-coverage reason: S5 현금영수증 프로바이더·환불 정합성 명세 SSoT. 11축 pairwise 13조합을 테스트 docblock @scenario 로 나열하는 것은 거짓 매핑이 된다 — 축(발급 프로바이더 / 환불 스코프 / 결제수단 / 에스크로)이 서로 다른 테스트 클래스에 격리 검증되어 한 메서드가 11축 전부를 재현하지 않는다(S4 toss-payment-methods-vbank-escrow · KG security-callback-defense 선례 동일). effects 27건은 전부 실제 검증 메서드에 @effects 로 매핑되어 있으며(미매핑 0건, 실측 확인), 그것이 이 기능의 실질 커버리지다.
|
||||
# 시나리오 매니페스트 — 현금영수증 프로바이더(토스·KG) · 환불 정합성 (S5)
|
||||
# 참조: docs/testing-guide.md "기능 단위 시나리오 매트릭스", 계획서 §12-5 / §13-3
|
||||
|
||||
feature: cash_receipt_provider_and_refund_integrity
|
||||
description: >-
|
||||
현금영수증 발급 프로바이더(토스페이먼츠 · KG이니시스)를 PG 결제사와 독립적으로 선택하는
|
||||
공용 훅 축과, 토스 환불 정합성 3건(전액취소·면세금액·가상계좌 환불계좌) +
|
||||
에스크로 부분취소 사전 차단 + 취소 멱등키의 입력 조합과 후속 효과.
|
||||
|
||||
test_files:
|
||||
# 현금영수증 프로바이더 (양 PG)
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/RegisterCashReceiptProviderListenerTest.php
|
||||
- plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/RegisterCashReceiptProviderListenerTest.php
|
||||
# 환불 정합성 + 에스크로 차단 + 멱등키
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/PaymentRefundListenerTest.php
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Unit/Services/TossPaymentsApiServiceTest.php
|
||||
# 코어 훅 계약 (환불 훅이 환불 레코드를 전달 — 멱등키 근거)
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php
|
||||
# 공용 발급/취소/재발급 서비스 (금액 변동 → 전액취소 후 재발급)
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashReceiptServiceTest.php
|
||||
# 활동 로그 실패가 결제 콜백을 죽이지 않는지 (본 세션 코어 결함 수정)
|
||||
- tests/Unit/ActivityLog/ResolvesActivityLogTypeTest.php
|
||||
# 레거시 KG 전용 발행 경로가 되살아나지 않는지 (이중 발행 경로 회귀 차단)
|
||||
- plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminRoutePermissionTest.php
|
||||
- plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/EnsureAdminOrderDetailPaymentQueryLayoutListenerTest.php
|
||||
|
||||
coverage_strategy: pairwise
|
||||
|
||||
# 입력 axis — 발급/취소/환불 경로에 영향을 주는 변수
|
||||
axes:
|
||||
cash_receipt_provider: [tosspayments, kginicis, none, other]
|
||||
receipt_type: [income, expense]
|
||||
identifier_type: [phone, card, business]
|
||||
tax_composition: [all_taxable, all_tax_free, mixed]
|
||||
issue_sequence: [first, reissued]
|
||||
refund_scope: [full, partial, remaining_after_partial]
|
||||
payment_method: [card, vbank, dbank]
|
||||
is_escrow: [false, true]
|
||||
refund_account: [present, absent]
|
||||
refund_record: [present, absent]
|
||||
pg_result: [success, api_error]
|
||||
|
||||
axis_notes:
|
||||
cash_receipt_provider: >-
|
||||
발급사는 PG 결제사와 독립 선택. none = 미설정(발급 자체 불가).
|
||||
other = 타 프로바이더 요청 → 각 리스너는 결과를 건드리지 않고 그대로 통과(early-return).
|
||||
receipt_type: >-
|
||||
코어 enum(income/expense). 프로바이더 종속 표현으로의 변환은 리스너 내부에서만 수행 —
|
||||
토스는 한글(소득공제/지출증빙), KG 는 issueType(0/1).
|
||||
identifier_type: 지출증빙도 휴대폰번호 허용(D10). 사업자번호는 체크섬 검증 대상.
|
||||
tax_composition: >-
|
||||
면세분은 발급/환불 양쪽에서 taxFreeAmount 로 동반 전달되어야 한다.
|
||||
미전달 시 복합과세 상점에서 면세분이 공급가액으로 오산되어 VAT 가 틀어진다.
|
||||
issue_sequence: >-
|
||||
재발급 시 동일 orderId 재사용은 토스가 중복 거부한다 → "{order_number}-cr{seq}" 접미.
|
||||
6~64자 · 영숫자/-/_ 제약을 벗어나면 API 호출 전에 실패시킨다.
|
||||
refund_scope: >-
|
||||
full = 전액취소(토스: cancelAmount 미지정). partial = 부분취소(금액 전달).
|
||||
remaining_after_partial = 이미 부분취소 이력이 있는 주문의 잔액 취소 —
|
||||
전액취소로 보내면 이미 취소된 금액까지 두 번 취소되므로 부분취소로 보내야 한다.
|
||||
is_escrow: >-
|
||||
결제 시점 스냅샷(payment.is_escrow)이 기준. 설정을 나중에 off 로 바꿔도
|
||||
기존 에스크로 주문의 부분취소는 계속 차단된다.
|
||||
refund_account: >-
|
||||
가상계좌 환불은 돌려보낼 계좌(은행·계좌번호·예금주)가 필수.
|
||||
미입력 시 PG 호출 전에 명시적으로 실패시킨다(토스가 거부하기 전에 차단).
|
||||
refund_record: >-
|
||||
코어가 환불 훅에 전달하는 OrderRefund. 이 값으로 취소 멱등키를 조립한다.
|
||||
absent = 레거시 호출(5-파라미터 리스너) — 멱등키 없이도 취소는 동작해야 한다.
|
||||
|
||||
# 의미 없는 조합 컷
|
||||
exclusions:
|
||||
- { cash_receipt_provider: none, issue_sequence: reissued, reason: "발급사 미설정 → 최초 발급 자체가 불가하므로 재발급 없음" }
|
||||
- { payment_method: card, refund_account: absent, reason: "카드 환불은 환불계좌 불요 — vbank 전용 요구사항" }
|
||||
- { payment_method: dbank, is_escrow: true, reason: "무통장 입금은 PG 에스크로 대상 아님" }
|
||||
- { is_escrow: true, refund_scope: partial, pg_result: success, reason: "에스크로 부분취소는 PG 호출 전 차단 — 성공 결과가 존재할 수 없음" }
|
||||
- { cash_receipt_provider: other, receipt_type: expense, reason: "타 프로바이더는 페이로드를 보지 않고 early-return — 용도 축 무의미" }
|
||||
|
||||
# 후속 효과 체인
|
||||
effects:
|
||||
# 프로바이더 등록 (PG 비종속)
|
||||
- provider_registered_without_overwriting_others
|
||||
- other_provider_request_passes_through_untouched
|
||||
# 발급
|
||||
- receipt_type_converted_to_provider_specific_value
|
||||
- order_id_suffixed_with_issue_sequence
|
||||
- order_id_constraint_violation_blocked_before_api_call
|
||||
- tax_free_amount_sent_with_issue
|
||||
- issue_response_mapped_to_receipt_key_and_issue_number
|
||||
- issue_failure_returns_result_without_throwing
|
||||
# 취소 / 재발급
|
||||
- cancel_delegated_as_full_cancel_without_amount
|
||||
- cancel_uses_issue_tid_as_receipt_key
|
||||
- amount_change_triggers_cancel_then_reissue
|
||||
# 환불 정합성 (R1/R2/R3)
|
||||
- full_refund_omits_cancel_amount
|
||||
- partial_refund_sends_cancel_amount
|
||||
- remaining_refund_after_partial_stays_partial
|
||||
- refund_sends_apportioned_tax_free_amount
|
||||
- vbank_refund_sends_refund_receive_account
|
||||
- vbank_refund_without_account_fails_before_api_call
|
||||
# 에스크로 (E4)
|
||||
- escrow_partial_refund_blocked_with_zero_api_calls
|
||||
- escrow_full_refund_allowed
|
||||
- escrow_blocking_uses_payment_snapshot_not_current_setting
|
||||
# 멱등키
|
||||
- cancel_request_carries_idempotency_key_from_refund_record
|
||||
- cancel_without_refund_record_still_succeeds
|
||||
# 코어 훅 계약
|
||||
- refund_hook_passes_order_refund_record
|
||||
- refund_hook_backward_compatible_with_five_arg_listener
|
||||
# 코어 안정성 (본 세션 결함 수정)
|
||||
- activity_log_failure_does_not_kill_triggering_request
|
||||
# 레거시 경로 제거 회귀
|
||||
- legacy_kg_cash_receipt_route_removed
|
||||
- legacy_kg_cash_receipt_panel_removed
|
||||
+2
-10
@@ -4,11 +4,11 @@ namespace Plugins\Sirsoft\VerificationKginicis\Tests\Unit\Identity;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use Mockery;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Identity\InicisIdentityProvider;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisChallengeMappingRepositoryInterface;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityRecordRepositoryInterface;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Services\InicisGatewayInterface;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* InicisIdentityProvider::withConfig() 회귀 테스트.
|
||||
@@ -24,7 +24,7 @@ use Plugins\Sirsoft\VerificationKginicis\Services\InicisGatewayInterface;
|
||||
* - 동일 cache 인스턴스가 전파됨
|
||||
* - config 만 병합되고 다른 의존성은 보존됨
|
||||
*/
|
||||
class InicisIdentityProviderWithConfigTest extends TestCase
|
||||
class InicisIdentityProviderWithConfigTest extends PluginTestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@@ -39,8 +39,6 @@ class InicisIdentityProviderWithConfigTest extends TestCase
|
||||
* 트리거되면 fatal 발생. 단, isAvailable 자체는 cache 를 호출하지 않으므로
|
||||
* 본 테스트는 "config 가 cache 위치에 들어가도 isAvailable 자체는 통과" 라는
|
||||
* 약한 보장만 줌. 더 강한 보장은 test_with_config_preserves_cache_instance 가 제공.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_with_config_returns_functional_instance(): void
|
||||
{
|
||||
@@ -60,8 +58,6 @@ class InicisIdentityProviderWithConfigTest extends TestCase
|
||||
*
|
||||
* Reflection 으로 protected $cache 를 직접 확인 — 회귀 시점에는 4번째 자리가
|
||||
* array 였으므로 이 검증이 실패함 (assertInstanceOf(CacheInterface, ...) fail).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_with_config_preserves_cache_instance(): void
|
||||
{
|
||||
@@ -81,8 +77,6 @@ class InicisIdentityProviderWithConfigTest extends TestCase
|
||||
|
||||
/**
|
||||
* withConfig 가 config 만 병합하고 다른 의존성은 원본 그대로 유지해야 한다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_with_config_merges_config_and_keeps_other_dependencies(): void
|
||||
{
|
||||
@@ -131,8 +125,6 @@ class InicisIdentityProviderWithConfigTest extends TestCase
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @param CacheInterface|null $cache
|
||||
* @return InicisIdentityProvider
|
||||
*/
|
||||
protected function makeProvider(array $config, ?CacheInterface $cache = null): InicisIdentityProvider
|
||||
{
|
||||
|
||||
+5
-3
@@ -2,15 +2,17 @@
|
||||
|
||||
namespace Plugins\Sirsoft\VerificationKginicis\Tests\Unit\Services;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Services\InicisCallbackOutcome;
|
||||
use Plugins\Sirsoft\VerificationKginicis\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* InicisCallbackOutcome DTO 단위 테스트.
|
||||
*
|
||||
* Pure unit (DB / Laravel 부트 불필요) — Value Object factory + bridge query 변환만 검증.
|
||||
* Value Object factory + bridge query 변환만 검증한다. 지금은 DB/컨테이너를 쓰지 않지만,
|
||||
* 확장 테스트는 PluginTestCase 를 상속한다 — 나중에 컨테이너/설정을 건드리는 단언이 추가될 때
|
||||
* 조용히 다른 환경에서 도는 테스트가 되는 것을 막는다.
|
||||
*/
|
||||
class InicisCallbackOutcomeTest extends TestCase
|
||||
class InicisCallbackOutcomeTest extends PluginTestCase
|
||||
{
|
||||
public function test_success_factory_produces_verified_outcome_with_token_and_challenge_id(): void
|
||||
{
|
||||
|
||||
@@ -60,21 +60,21 @@ class ResolvesActivityLogTypeTest extends TestCase
|
||||
// resolveLogType 테스트 — 요청 경로 기반 판별
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public function test_resolveLogType_returns_admin_for_admin_route(): void
|
||||
public function test_resolve_log_type_returns_admin_for_admin_route(): void
|
||||
{
|
||||
$this->setRequestPath('api/admin/products');
|
||||
|
||||
$this->assertEquals(ActivityLogType::Admin, $this->traitUser->getResolvedLogType());
|
||||
}
|
||||
|
||||
public function test_resolveLogType_returns_user_for_shop_route(): void
|
||||
public function test_resolve_log_type_returns_user_for_shop_route(): void
|
||||
{
|
||||
$this->setRequestPath('api/shop/cart');
|
||||
|
||||
$this->assertEquals(ActivityLogType::User, $this->traitUser->getResolvedLogType());
|
||||
}
|
||||
|
||||
public function test_resolveLogType_returns_user_for_admin_on_user_route(): void
|
||||
public function test_resolve_log_type_returns_user_for_admin_on_user_route(): void
|
||||
{
|
||||
// 핵심 테스트: admin 역할이지만 사용자 화면 경로 → user
|
||||
$this->setRequestPath('api/shop/cart');
|
||||
@@ -82,7 +82,7 @@ class ResolvesActivityLogTypeTest extends TestCase
|
||||
$this->assertEquals(ActivityLogType::User, $this->traitUser->getResolvedLogType());
|
||||
}
|
||||
|
||||
public function test_resolveLogType_returns_user_for_guest_on_user_route(): void
|
||||
public function test_resolve_log_type_returns_user_for_guest_on_user_route(): void
|
||||
{
|
||||
// 비회원이 사용자 화면 경로 → user (System이 아님)
|
||||
$this->setRequestPath('api/shop/coupons/download');
|
||||
@@ -90,14 +90,14 @@ class ResolvesActivityLogTypeTest extends TestCase
|
||||
$this->assertEquals(ActivityLogType::User, $this->traitUser->getResolvedLogType());
|
||||
}
|
||||
|
||||
public function test_resolveLogType_returns_user_for_public_api_route(): void
|
||||
public function test_resolve_log_type_returns_user_for_public_api_route(): void
|
||||
{
|
||||
$this->setRequestPath('api/auth/register');
|
||||
|
||||
$this->assertEquals(ActivityLogType::User, $this->traitUser->getResolvedLogType());
|
||||
}
|
||||
|
||||
public function test_resolveLogType_returns_system_for_cli_without_request(): void
|
||||
public function test_resolve_log_type_returns_system_for_cli_without_request(): void
|
||||
{
|
||||
// CLI 환경: request()->path() === '/'
|
||||
$this->setRequestPath('/');
|
||||
@@ -105,7 +105,7 @@ class ResolvesActivityLogTypeTest extends TestCase
|
||||
$this->assertEquals(ActivityLogType::System, $this->traitUser->getResolvedLogType());
|
||||
}
|
||||
|
||||
public function test_resolveLogType_returns_admin_for_nested_admin_route(): void
|
||||
public function test_resolve_log_type_returns_admin_for_nested_admin_route(): void
|
||||
{
|
||||
$this->setRequestPath('api/admin/sirsoft-ecommerce/orders/123/cancel');
|
||||
|
||||
@@ -116,7 +116,7 @@ class ResolvesActivityLogTypeTest extends TestCase
|
||||
// logActivity 테스트
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public function test_logActivity_auto_resolves_log_type_when_not_provided(): void
|
||||
public function test_log_activity_auto_resolves_log_type_when_not_provided(): void
|
||||
{
|
||||
$this->setRequestPath('api/admin/products');
|
||||
|
||||
@@ -136,7 +136,7 @@ class ResolvesActivityLogTypeTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_logActivity_preserves_explicit_log_type(): void
|
||||
public function test_log_activity_preserves_explicit_log_type(): void
|
||||
{
|
||||
// 경로는 admin이지만 명시적으로 User 타입이 전달된 경우 유지
|
||||
$this->setRequestPath('api/admin/products');
|
||||
@@ -157,7 +157,7 @@ class ResolvesActivityLogTypeTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_logActivity_catches_exception_and_logs_error(): void
|
||||
public function test_log_activity_catches_exception_and_logs_error(): void
|
||||
{
|
||||
$this->setRequestPath('/');
|
||||
|
||||
@@ -181,7 +181,42 @@ class ResolvesActivityLogTypeTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_logActivity_resolves_system_type_for_cli_context(): void
|
||||
/**
|
||||
* 활동 로그 채널 해석이 실패해(Error) 도 그것을 유발한 요청까지 죽이지 않는지 검증
|
||||
*
|
||||
* Log::channel('activity') 가 null 을 반환하면 "->info() on null" 은 \Error 이며
|
||||
* \Exception 이 아니다. 가드가 \Exception 만 잡으면 결제 콜백 같은 호출자가
|
||||
* 활동 로그 때문에 500 으로 죽는다.
|
||||
*
|
||||
* @effects activity_log_failure_does_not_kill_triggering_request
|
||||
*/
|
||||
public function test_log_activity_catches_error_not_only_exception(): void
|
||||
{
|
||||
$this->setRequestPath('/');
|
||||
|
||||
$logChannel = Mockery::mock(LoggerInterface::class);
|
||||
Log::shouldReceive('channel')->with('activity')->andReturn($logChannel);
|
||||
|
||||
$logChannel->shouldReceive('info')
|
||||
->once()
|
||||
->andThrow(new \Error('Call to a member function info() on null'));
|
||||
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->withArgs(function (string $message, array $context) {
|
||||
return $message === 'Failed to record activity log'
|
||||
&& $context['action'] === 'test.action';
|
||||
});
|
||||
|
||||
// 예외가 밖으로 새어나오면 이 테스트는 실패한다 (호출자가 죽는다는 뜻).
|
||||
$this->traitUser->callLogActivity('test.action', [
|
||||
'description_key' => 'test_key',
|
||||
]);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_log_activity_resolves_system_type_for_cli_context(): void
|
||||
{
|
||||
// CLI 환경 (request()->path() === '/')
|
||||
$this->setRequestPath('/');
|
||||
@@ -207,7 +242,7 @@ class ResolvesActivityLogTypeTest extends TestCase
|
||||
/**
|
||||
* 테스트용 Request 경로를 설정합니다.
|
||||
*
|
||||
* @param string $path 요청 경로 (예: 'api/admin/products', '/' for CLI 시뮬레이션)
|
||||
* @param string $path 요청 경로 (예: 'api/admin/products', '/' for CLI 시뮬레이션)
|
||||
*/
|
||||
private function setRequestPath(string $path): void
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user