Skip to Content
개념환불

환불

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을 열면, 페이지는 양식 대신 새 링크 요청 버튼을 제공합니다. 클릭 시:

  1. /pub/v1/refund-requests/:token/request-renewal로 POST(자격증명 없음 — 토큰 자체가 진실의 베어러임)
  2. 선택적으로 구매자가 가맹점에 남길 자유 텍스트 메모(customer_note) 캡처
  3. 토큰을 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.sourceb2b / 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.* 프리픽스 아래의 자체 이벤트 시리즈를 가집니다.

다음 단계