환불
Refund는 Order의 플래그가 아닌 일급 엔티티입니다. 부분 환불, 동일 Order에 대한 여러 환불, 또는 동일한 흐름에서 환불 + 재과금을 수행할 수 있습니다.
환불 기록이 생성되는 두 가지 경로가 있습니다.
| 흐름 | 양식 작성자 | 인증 | 도착 상태 |
|---|---|---|---|
| 가맹점 시작 | 가맹점 대시보드 / 백엔드 | HMAC (sk_…) | 즉시 APPROVED |
| 고객 시작 | 호스팅 페이지에서 구매자 | 일회성 토큰(자격증명 없음) | PENDING — 가맹점이 승인하거나, 자동 승인 설정 시 단락 처리 |
고객 시작 흐름은 단명 환불 요청 토큰을 사용합니다. 가맹점은 토큰을
발급(B2B 또는 대시보드)하고 원하는 방식으로 구매자에게 URL을 전달합니다.
구매자는 checkout.infraio.xyz/refund-request/:token에서 환불 세부 사항을
완료합니다. 구매자는 가맹점 API에 접근하지 않으며, 가맹점 키도 보지 않습니다.
환불 라이프사이클
| 상태 | 의미 |
|---|---|
PENDING | 환불이 기록되어 승인 대기 중. 고객 시작 환불은 항상 여기서 시작합니다. |
APPROVED | 실행 승인됨. 가맹점 시작 환불은 바로 여기로 이동합니다. |
REJECTED | 환불 거부됨. Order 상태는 변경되지 않습니다. |
EXECUTED | 온체인 송금 확인됨. Order는 PARTIALLY_REFUNDED / REFUNDED로 이동합니다. |
가맹점 시작
환불을 결정한 경우(예: 구매자가 채팅으로 불만 제기). 가맹점 시작 엔드포인트를
호출하세요 — 리뷰를 건너뛰고 즉시 APPROVED 상태가 됩니다.
POST /b2b/v1/merchants/{merchant_id}/refunds
{
"order_id": "ord_01J5K…",
"amount": "49.00", // 부분 또는 전체. 주문의 표시 통화 기준
"reason": "customer complaint #4521",
"refund_to_address": "0xBUYER…", // 암호화폐 레일에서 필수
"refund_network": "polygon", // 네트워크 슬러그. 개념 → 체인 참조
"refund_token_address":"0xUSDC_CONTRACT" // 환불할 ERC-20 컨트랙트. 보통 원본 토큰
}환불 요청에는 currency 필드가 없습니다 — 환불은 항상 주문의 표시 통화
(현재 USD)를 상속합니다. (refund_to_address, refund_network, refund_token_address) 트리플은 온체인 목적지입니다. payment-service는
이를 사용하여 암호화폐 saga를 구동합니다. 법정화폐 레일에서는 무시됩니다
(제공자가 자동 라우팅).
온체인 송금을 실행할 때까지 Order는 기존 상태를 유지합니다(암호화폐 환불 실행 참조).
고객 시작 — 환불 요청 토큰
구매자는 가맹점이 아닌 저희 호스팅 페이지에서 환불 양식을 작성합니다. 가맹점이 할 일은 토큰을 발급하고 URL을 전달하는 것뿐입니다.
토큰 라이프사이클
| 상태 | 의미 | 고객 URL 표시 |
|---|---|---|
ACTIVE | 토큰이 활성 상태, now < expires_at | 환불 양식(refund_to_address, reason, amount, 선택적 메모 → metadata.note) |
SUBMITTED | 구매자가 양식 작성 완료. Refund 행이 존재함 | /r/:linkToken을 미러링한 상태 카드 |
EXPIRED_UNUSED | 구매자가 제출하기 전 TTL 경과 | 안내: “이 링크는 만료되었습니다. 새 링크를 요청하세요” |
RENEWAL_REQUESTED | 구매자가 새 링크 요청함 | 대기 안내: “가맹점에 알림이 전송되었습니다” |
RENEWED | 가맹점이 갱신을 승인하고 대체 토큰을 발급함 | ”이 링크는 교체되었습니다 — 이메일에서 새 링크를 확인하세요” (전달된 링크 공격을 차단하기 위해 새 토큰은 여기에 노출되지 않음) |
CANCELED | 가맹점이 대시보드에서 토큰을 취소함 | 단순 표시: “이 환불 요청은 취소되었습니다” |
토큰은 일회용입니다. SUBMITTED 상태가 되면 구매자가 상태를 확인하기 위해
URL은 계속 유효하지만 다시 제출하는 데 사용할 수는 없습니다. 동일한 주문에
대해 두 번째 환불을 발행하려면 새 토큰을 발급하세요.
TTL 기본값
| 발급 소스 | 기본 TTL | 이유 |
|---|---|---|
POST /b2b/v1/merchants/{merchant_id}/refund-requests (HMAC) | 30분 | 프로그래밍 방식 — 즉시 구매자에게 전달된다고 가정합니다. |
POST /payment/v1/merchants/{merchant_id}/refund-requests (대시보드 JWT) | 24시간 | 수동 — 가맹점이 URL을 이메일/SMS에 붙여넣습니다. |
두 엔드포인트 모두 본문에 ttl_seconds 필드를 받아 오버라이드할 수 있습니다.
현재 서버 측에서 강제되는 하드 최소/최대값은 없습니다 — 일반적인 값은 1분에서
7일 사이입니다. 이 범위 내에서 사용하여 구매자를 놀라게 하지 않고 취소된
토큰에 용량을 묶어 두지 않도록 하세요.
B2B API를 통한 발급
지원 대화 후 또는 주문 취소 흐름 등에서 환불 링크를 프로그래밍 방식으로 생성하려는 백엔드를 위한 것입니다.
POST /b2b/v1/merchants/{merchant_id}/refund-requests
Content-Type: application/json
X-Client-ID: pk_live_…
X-Timestamp: 1729536000
X-Signature: 4f2a1b9c8d3e2f1a0b9c8d7e6f5a4b3c…
{
"ref_type": "order_id", // 필수: order_id | order_number | session_id | session_key
"ref_value": "ord_01J5K…", // 필수: ref_type과 매칭
"amount": "49.00", // 필수 — 구매자가 제출할 수 있는 최대 금액을 잠금
"ttl_seconds": 1800, // 선택 — 기본값 1800 (30분)
"metadata": { "support_ticket": "4521" }, // 선택 — Stripe 스타일 키/값
"hide_summary": false, // 호스팅 양식에 대한 선택적 UI 플래그
"hide_header": false
}B2B 요청 서명은 소문자 hex 그대로이며 sha256= 프리픽스가 없습니다
— 해당 프리픽스는 인바운드 웹훅 서명(Infraio → 가맹점 서버)에만
나타납니다. 아웃바운드 B2B 서명 문자열은 METHOD\nPATH\nTIMESTAMP\nBODY
입니다. 정규 알고리즘은 인증을
참조하세요.
금액은 발급 본문에 포함되며 필수입니다. 이는 구매자가 양식에서 제출할 수 있는 한도를 잠급니다 — 더 적게 제출할 수는 있지만 더 많이는 안 됩니다. (부분 환불의 경우 부분 금액으로 토큰을 발급하세요. 전체 환불의 경우 주문 총액으로 발급하세요.)
레거시 { "order_id": "..." } 형식도 하위 호환성을 위해 여전히 허용되며 —
내부적으로는 (ref_type=order_id, ref_value=...)로 매핑됩니다 — 새 통합에서는
명시적인 ref_type + ref_value 쌍을 사용해야 합니다.
응답:
{
"token": "rfqt_01J7P3Q9R…",
"refund_url": "https://checkout.infraio.xyz/refund-request/rfqt_01J7P3Q9R…",
"expires_at": "2026-05-28T10:32:00Z"
}웹훅 엔드포인트로 refund_request.created가 발생합니다(주문에 대해 현재
활성화된 토큰을 로깅/감사할 수 있도록).
대시보드를 통한 발급
가맹점 대시보드 의 Issue Refund 모달은 토글을
노출합니다 — 지금 실행 또는 고객에게 링크 전송. 후자를 선택하면
백그라운드에서 POST /payment/v1/merchants/{merchant_id}/refund-requests를
호출하고(JWT 인증, 위의 B2B와 동일한 본문 형식), 복사 버튼과 QR 코드를 포함한
URL을 표시합니다. 적절한 채널에 붙여넣기만 하면 됩니다 — 이메일, 지원 채팅,
SMS 등.
JavaScript SDK를 통해 — openRefundRequest
이미 @lartech/infraio-checkout-js가 스택에 있고 구매자가 외부 URL 대신
가맹점 페이지 흐름 내에서 환불을 완료하기를 원하는 경우, B2B 발급과
sdk.openRefundRequest()를 결합하세요.
// 서버 측: 토큰 발급
const { token } = await fetch("/api/mint-refund-token", { method: "POST" }).then(r => r.json());
// 클라이언트 측: 호스팅 양식 열기
const sdk = await loadInfraIo("pk_live_yourkeyhere");
const close = sdk.openRefundRequest({
token,
mode: "popup", // 또는 "redirect" | "embed"
onSuccess: ({ linkToken, refundId }) => {
// linkToken → /r/:linkToken 구매자 상태 페이지.
// refundId → 승인/거부를 위한 B2B API 참조.
window.location.href = `/r/${linkToken}`;
},
onCancel: () => { /* 구매자가 팝업을 닫음 */ },
onError: (err) => { /* SDK 레퍼런스 참조 */ },
});전체 옵션 표는 SDK 레퍼런스 → sdk.openRefundRequest()를
참조하세요.
고객 갱신 — 구매자 주도 재발급
구매자가 토큰이 만료된 후 URL을 열면, 페이지는 양식 대신 새 링크 요청 버튼을 제공합니다. 클릭 시:
/pub/v1/refund-requests/:token/request-renewal로 POST(자격증명 없음 — 토큰 자체가 진실의 베어러임)- 선택적으로 구매자가 가맹점에 남길 자유 텍스트 메모(
customer_note) 캡처 - 토큰을
RENEWAL_REQUESTED로 이동하고 웹훅으로refund_request.renewal_requested발생
가맹점 대시보드의 갱신 요청 위젯에 배지가 표시됩니다. 한 번의 클릭으로
승인하면 시스템이 새 ACTIVE 토큰을 발급하고, refund_request.renewed를
발생시키며, 다시 전송할 수 있도록 새 URL을 복사할 수 있게 합니다. 이전 URL은
계속 접근 가능하지만 “교체됨 — 이메일을 확인하세요”로 렌더링되므로 이전 URL이
전달되어도 새 URL을 추출하는 데 사용할 수 없습니다.
암호화폐 환불 실행
API는 인텐트를 기록할 뿐 자금을 이동시키지 않습니다. 가맹점이 가맹점 지갑에서 온체인 송금을 서명하고 브로드캐스트한 다음, tx 해시를 환불 기록에 스탬프해야 합니다.
POST /b2b/v1/refunds/:refund_id/submit-tx
Content-Type: application/json
{
"tx_hash": "0xabcd…",
"network": "ethereum",
"token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
}세 본문 필드 모두 필수입니다. 동일한 tx 해시가 다른 체인에 존재할 수 있고, 원래 결제가 캡처된 것과 다른 스테이블코인으로 환불할 수도 있기 때문입니다.
체인 워처가 해당 tx가 구성된 확인 수(체인 및 자산
참조)를 클리어하는 것을 확인하면, 환불은 EXECUTED로 전환되고 Order의
환불 합계가 업데이트됩니다.
저희는 의도적으로 가맹점 자금을 수탁하지 않으므로 가맹점을 대신하여
환불을 실행할 수 없습니다. 온체인 전송을 자체 어드민 도구에 내장하세요 —
멀티시그 또는 핫 월렛에서 eth_sendRawTransaction을 호출하고, tx 해시를
환불 API에 게시하는 워크플로우로 마무리하세요.
웹훅 이벤트
환불 서브시스템은 두 가지 이벤트 패밀리를 발생시킵니다.
토큰 라이프사이클 (refund_request.*)
| 이벤트 | 발생 시점 |
|---|---|
refund_request.created | 토큰 발급됨 — data.source는 b2b / dashboard / renewal 중 하나 |
refund_request.renewal_requested | 구매자가 토큰 만료 후 “새 링크 요청”을 클릭함. 이 이벤트를 구독하세요 — 가맹점이 조치를 취해야 한다는 신호입니다. |
refund_request.renewed | 갱신이 승인되어 새 토큰이 이전 토큰을 대체함. data.old_token / data.new_token이 감사 체인을 형성합니다. |
refund_request.canceled | 가맹점이 대시보드에서 토큰을 CANCELED로 전환함. 멱등적 — 첫 전이만 발생합니다. data.reason은 선택적 가맹점 메모입니다. |
환불 라이프사이클 (payment.refund.*)
| 이벤트 | 발생 시점 |
|---|---|
payment.refund.requested | 새 Refund 행이 존재함 — 양식 제출, 가맹점 시작 API, 또는 대시보드 소스. |
payment.refund.approved | 환불이 승인됨 — 자동 승인(가맹점 시작) 또는 대기 중인 환불에 대해 /approve를 호출한 후. |
payment.refund.rejected | 대기 중인 환불에 대해 /reject를 호출함. |
payment.refund.executed | 자금이 이동함(암호화폐 tx 해시가 필요한 확인 수에 도달함). |
payment.failed는 환불에 대해 발생하지 않습니다 — 환불은 payment.refund.*
프리픽스 아래의 자체 이벤트 시리즈를 가집니다.
다음 단계
- SDK 레퍼런스 →
sdk.openRefundRequest()— 호스팅 환불 양식을 팝업/리디렉션/임베드로 엽니다. - API 레퍼런스 → 환불 — 엔드포인트 카탈로그(발급, 제출, 갱신, 상태).
- 개념 → 주문 — Refund 상태가 Order 라이프사이클과 어떻게 연결되는지를 다룹니다.
- 웹훅 → 개요 — 전체 이벤트 카탈로그.