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:
HeuJung committed 2026-08-06 12:58:59 +09:00
1 parent a9f85ff92b
commit faa79b0ae1
95 files changed
+6637 -1372

No files matched your search

@@ -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 | 통보 메시지. 파이프(`&#124;`) 구분자의 첫 조각에서 가상계좌번호를 추출해 발급 계좌와 대조하는 데 사용된다. |
| 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
View File
@@ -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,
File diff suppressed because it is too large. Load diff
@@ -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);
}
}
@@ -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')
@@ -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);
}
}
@@ -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 전용 현금영수증 발행 라우트는 공용 프로바이더 축으로 이관되어 제거되어야 합니다.'
);
}
}
@@ -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;
}
@@ -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
{
@@ -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
{
@@ -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
{
@@ -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;
}
@@ -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']);
}
}
@@ -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)
)
);
}
}
@@ -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
View File
@@ -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 서비스 인스턴스를 가져옵니다.
*/
@@ -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);
}
}
@@ -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()
);
@@ -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,
@@ -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']);
}
}
@@ -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']);
}
}
@@ -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'));
}
}
@@ -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
@@ -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
{
@@ -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
{