<!-- Source: https://docs.infraio.xyz/vi/concepts/refunds -->
<!-- Last updated: 2026-10-04 -->

# Hoàn tiền

**Refund** là một entity hạng nhất, không phải một cờ trên Order. Bạn
có thể phát hành refund từng phần, nhiều refund cùng cho một Order,
hoặc refund + tính phí lại trong cùng một luồng.

> **Note:**
>
> Refund cũng có thể được tạo từ [merchant app](https://docs.infraio.xyz/vi/get-started/merchant-app).

Có hai cách bản ghi refund có thể được tạo ra:

| Luồng | Ai điền form | Auth | Trạng thái khi tạo |
| --- | --- | --- | --- |
| Merchant khởi tạo | Dashboard / backend của bạn | HMAC (sk_…) | `APPROVED` ngay lập tức |
| Khách hàng khởi tạo | Người mua, trên trang do hệ thống host | Token dùng một lần (không cần creds) | `PENDING` — bạn approve, hoặc tự động ngắn mạch nếu cấu hình auto-approve |

Luồng do khách hàng khởi tạo dùng một **token refund-request** ngắn
hạn. Bạn phát hành token (qua B2B hoặc dashboard), giao URL cho người
mua theo cách bạn muốn, và người mua hoàn tất chi tiết refund trên
`checkout.infraio.xyz/refund-request/:token`. Người mua không bao giờ
chạm vào API của bạn và không bao giờ thấy merchant key của bạn.

## Vòng đời refund

```mermaid
stateDiagram-v2
    [*] --> PENDING:  refund created (customer submit or B2B customer-flow)
    PENDING --> APPROVED: passes review (auto for merchant-initiated)
    PENDING --> REJECTED: review denies
    APPROVED --> EXECUTED: on-chain tx confirmed
    APPROVED --> REJECTED: canceled before execution
    REJECTED --> [*]
    EXECUTED --> [*]
```

| Trạng thái | Ý nghĩa |
| --- | --- |
| `PENDING` | Refund đã ghi nhận, đang chờ approve. Refund do khách hàng khởi tạo luôn bắt đầu ở đây. |
| `APPROVED` | Đã được duyệt để thực thi. Refund do merchant khởi tạo nhảy thẳng vào đây. |
| `REJECTED` | Refund bị từ chối. Trạng thái Order không đổi. |
| `EXECUTED` | Giao dịch chuyển on-chain đã được xác nhận. Order chuyển sang `PARTIALLY_REFUNDED` / `REFUNDED`. |

---

## Merchant khởi tạo

Bạn quyết định refund (ví dụ người mua khiếu nại qua chat). Gọi endpoint
do merchant khởi tạo — nó bỏ qua review và lập tức rơi vào `APPROVED`.

```http
POST /b2b/v1/merchants/{merchant_id}/refunds
{
  "order_id": "ord_01J5K…",
  "amount": "49.00",                       // partial hoặc full, theo display currency của order
  "reason": "customer complaint #4521",
  "refund_to_address":   "0xBUYER…",       // bắt buộc cho rail crypto
  "refund_network":      "polygon",        // network slug; xem Khái niệm → Chains
  "refund_token_address":"0xUSDC_CONTRACT" // contract ERC-20 được trả lại; thường là token gốc
}
```

Không có trường `currency` trong refund request — refund luôn kế thừa
display currency của order (USD hiện tại). Bộ ba `(refund_to_address,
refund_network, refund_token_address)` là đích on-chain. Chúng bị bỏ qua với rail fiat
(provider tự định tuyến).

Order giữ nguyên trạng thái hiện tại cho đến khi bạn thực thi chuyển
on-chain (xem [Thực thi refund crypto](#thực-thi-refund-crypto)).

---

## Khách hàng khởi tạo — token refund-request

Người mua điền form refund **trên trang do hệ thống host**, không phải
trang của bạn. Việc duy nhất của bạn là phát hành token và chuyển URL.

### Vòng đời token

```mermaid
stateDiagram-v2
    [*] --> ACTIVE:           mint (B2B or dashboard)
    ACTIVE --> SUBMITTED:     buyer submits the form
    ACTIVE --> EXPIRED_UNUSED: now > expires_at
    ACTIVE --> CANCELED:      merchant cancels (dashboard)
    EXPIRED_UNUSED --> RENEWAL_REQUESTED: buyer clicks "Request new link"
    RENEWAL_REQUESTED --> RENEWED: merchant approves, new ACTIVE token issued
    SUBMITTED --> [*]
    CANCELED --> [*]
    RENEWED --> [*]
```

| Trạng thái | Ý nghĩa | URL khách hàng hiển thị |
| --- | --- | --- |
| `ACTIVE` | Token đang chạy, `now < expires_at` | Form refund (`refund_to_address`, `reason`, `amount`, ghi chú tùy chọn → `metadata.note`) |
| `SUBMITTED` | Người mua đã hoàn tất form; một bản ghi refund đã tồn tại | Thẻ trạng thái mirror `/r/:linkToken` |
| `EXPIRED_UNUSED` | TTL đã hết trước khi người mua submit | Thông báo: "This link has expired. Request a new one" |
| `RENEWAL_REQUESTED` | Người mua đã yêu cầu link mới | Thông báo chờ: "Your merchant has been notified" |
| `RENEWED` | Merchant đã approve renewal và phát hành thay thế | "This link has been replaced — check your email for the new link" (token mới **không** bị tiết lộ ở đây để vô hiệu hóa kiểu tấn công forward link) |
| `CANCELED` | Merchant đã thu hồi token từ dashboard | "This refund request was canceled" đơn giản |

> **Note:**
>
> Token là single-use. Khi đã `SUBMITTED`, URL vẫn còn hiệu lực để
> người mua kiểm tra trạng thái nhưng không thể submit lại. Để phát
> hành refund thứ hai cho cùng order, hãy phát hành token mới.

### TTL mặc định

| Nguồn phát hành | TTL mặc định | Vì sao |
| --- | --- | --- |
| `POST /b2b/v1/merchants/{merchant_id}/refund-requests` (HMAC) | **30 phút** | Programmatic — giả định được giao cho người mua ngay lập tức. |
| Merchant dashboard | **24 giờ** | Thủ công — merchant paste URL vào email / SMS. |

Bạn có thể override mặc định bằng trường body `ttl_seconds`. Không có
ngưỡng tối thiểu hay tối đa nào được enforce; giá trị thường gặp từ
1 phút đến 7 ngày.

### Phát hành qua B2B API

Dành cho backend muốn tự động tạo link refund ngay sau một cuộc hội
thoại support, một luồng hủy order, v.v.

```http
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",                          // required: order_id | order_number | session_id | session_key
  "ref_value":   "ord_01J5K…",                        // required: matches ref_type
  "amount":      "49.00",                             // required — locks the maximum the buyer can submit
  "ttl_seconds": 1800,                                // optional — defaults to 1800 (30 min)
  "metadata":    { "support_ticket": "4521" },        // optional — Stripe-style key/value
  "hide_summary": false,                              // optional UI flags for the hosted form
  "hide_header":  false
}
```

> **Warning:**
>
> Chữ ký request B2B là **hex lowercase thuần** với **không có prefix
> `sha256=`** — prefix đó chỉ xuất hiện trên chữ ký webhook *inbound*
> (Infraio → server của bạn). Chuỗi ký B2B outbound là
> `METHOD\nPATH\nTIMESTAMP\nBODY`; xem
> [Xác thực](https://docs.infraio.xyz/vi/api-reference/authentication) để biết thuật toán canonical.

Trường amount **nằm** trong body phát hành và **bắt buộc**. Nó khóa
trần mà người mua có thể submit trên form — họ có thể submit ít hơn
nhưng không bao giờ vượt quá. (Với refund partial, phát hành token với
số partial; với refund đầy đủ, phát hành với tổng order.)

Cấu trúc cũ `{ "order_id": "..." }` vẫn được chấp nhận và được coi là
`ref_type=order_id`, nhưng các tích hợp mới nên dùng cặp `ref_type` +
`ref_value` tường minh.

### Phát hành qua dashboard

Modal Issue Refund trong [merchant dashboard](https://app.infraio.xyz)
bộc lộ một toggle: **Execute now** vs **Send link to customer**. Chọn
mục thứ hai sẽ tạo một token refund-request (giống lệnh gọi B2B ở trên)
và hiển thị URL cho bạn cùng nút copy và QR code. Paste vào kênh phù hợp —
email, support chat, SMS.

### Qua JavaScript SDK — `openRefundRequest`

Nếu bạn đã có `@lartech/infraio-checkout-js` trong stack và muốn người
mua hoàn tất refund bên trong luồng trang của bạn (không qua URL bên
ngoài), kết hợp lệnh phát hành B2B với `sdk.openRefundRequest()`:

```ts
// Server-side: phát hành token
const { token } = await fetch("/api/mint-refund-token", { method: "POST" }).then(r => r.json());

// Client-side: mở form do hệ thống host
const sdk = await loadInfraIo("pk_live_yourkeyhere");
const close = sdk.openRefundRequest({
  token,
  mode: "popup",                       // hoặc "redirect" | "embed"
  onSuccess: ({ linkToken, refundId }) => {
    // linkToken → trang trạng thái /r/:linkToken cho người mua.
    // refundId  → reference B2B API để approve / reject.
    window.location.href = `/r/${linkToken}`;
  },
  onCancel: () => { /* người mua đã đóng popup */ },
  onError:  (err) => { /* xem tham chiếu SDK */ },
});
```

Xem [Tham chiếu SDK → `sdk.openRefundRequest()`](https://docs.infraio.xyz/vi/sdks/javascript#sdkopenrefundrequest-)
để biết bảng option đầy đủ.

### Customer renewal — phát hành lại do người mua chủ động

Nếu người mua mở URL sau khi token đã hết hạn, trang sẽ hiển thị nút
**Request new link** thay cho form. Bấm vào sẽ:

1. Gửi yêu cầu renewal (không cần creds; bản thân link đã cấp quyền)
2. Tùy chọn ghi nhận một ghi chú tự do (`customer_note`) mà người mua
   có thể để lại cho bạn
3. Chuyển token sang `RENEWAL_REQUESTED` và phát
   `refund_request.renewal_requested` đến webhook của bạn

Dashboard của bạn sẽ hiển thị badge trên widget renewal-requests. Approve
nó (một cú click) và một token `ACTIVE` mới được phát hành, phát
`refund_request.renewed`, và cho phép bạn copy URL mới để gửi lại. URL
cũ vẫn truy cập được nhưng hiển thị "Replaced — check your email" để
một bản copy URL cũ bị forward không thể dùng để dò ra URL mới.

---

## Thực thi refund crypto

API ghi nhận intent — nó không di chuyển vốn. **Bạn** ký và broadcast
giao dịch chuyển on-chain từ ví của mình, rồi stamp tx hash
ngược trở lại bản ghi refund:

```http
POST /b2b/v1/refunds/:refund_id/submit-tx
Content-Type: application/json

{
  "tx_hash": "0xabcd…",
  "network": "ethereum",
  "token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
}
```

Cả ba trường body đều bắt buộc: cùng một tx hash có thể tồn tại trên
các chain khác nhau, và bạn có thể refund bằng stablecoin khác với
loại mà thanh toán gốc đã capture.

Khi InfraIO Pay thấy giao dịch đó đạt đủ số confirmation yêu cầu (xem
[Chains & tài sản](https://docs.infraio.xyz/vi/concepts/chains)), refund chuyển sang `EXECUTED`
và tổng số đã refund của Order được cập nhật.

> **Warning:**
>
> Chúng tôi chủ ý không giữ custody vốn của merchant, nghĩa là chúng
> tôi không thể thực thi refund thay bạn. Hãy xây phần gửi on-chain
> vào tool admin của bạn — `eth_sendRawTransaction` từ một multisig
> hoặc ví hot, với workflow kết thúc bằng việc POST tx hash vào refund
> API.

### Refund trên TRON, Solana và TON

Quy trình vẫn như cũ: bạn gửi refund từ ví của chính mình, rồi submit tx hash. Chi tiết thay đổi theo network:

- Màn hình refund trong dashboard hiển thị đích đến, số tiền, network và token, kèm mã QR ở những network hỗ trợ: QR Solana Pay trên Solana và link chuyển TON trên TON. Trên TRON, màn hình hiển thị địa chỉ đích để bạn copy (không có link ví nào mang theo số tiền), nên bạn tự nhập số tiền.
- `token_address` là địa chỉ của token trên network đó: contract TRC-20, mint SPL, hoặc địa chỉ Jetton master.
- Định dạng tx hash khác nhau: hex trần trên TRON, chữ ký base58 trên Solana, hash hex hoặc base64 trên TON.
- Nền tảng xác minh đúng giao dịch đó on-chain, rồi chuyển refund sang `EXECUTED`, dựa trên số confirmation trong [Chains & tài sản](https://docs.infraio.xyz/vi/concepts/chains).

---

## Sự kiện webhook

Subsystem refund phát hai họ event:

### Vòng đời token (`refund_request.*`)

| Event | Phát khi |
| --- | --- |
| `refund_request.created` | Một token được phát hành — `data.source` là `b2b` / `dashboard` / `renewal` |
| `refund_request.renewal_requested` | Một người mua bấm "Request new link" sau khi token của họ hết hạn. **Hãy subscribe — đây là tín hiệu để merchant hành động.** |
| `refund_request.renewed` | Bạn đã approve một renewal và một token mới thay thế cái cũ. `data.old_token` / `data.new_token` tạo thành chuỗi audit. |
| `refund_request.canceled` | Bạn đã chuyển một token sang `CANCELED` từ dashboard. Idempotent — chỉ transition đầu tiên phát event. `data.reason` là ghi chú tùy chọn của merchant. |

### Vòng đời refund (`payment.refund.*`)

| Event | Phát khi |
| --- | --- |
| `payment.refund.requested` | Một bản ghi Refund mới tồn tại — từ bất kỳ nguồn nào (form submit, API do merchant khởi tạo, dashboard). |
| `payment.refund.approved` | Refund được approve — hoặc auto-approve (do merchant khởi tạo) hoặc sau khi bạn gọi `/approve` trên một refund pending. |
| `payment.refund.rejected` | Bạn đã gọi `/reject` trên một refund pending. |
| `payment.refund.executed` | Vốn đã di chuyển (tx hash crypto của bạn đã đạt confirmation yêu cầu). |

`payment.failed` **không** được phát cho refund — refund có chuỗi
event riêng dưới prefix `payment.refund.*`.

## Tiếp theo

- [Tham chiếu SDK → `sdk.openRefundRequest()`](https://docs.infraio.xyz/vi/sdks/javascript#sdkopenrefundrequest-) — mở form refund do hệ thống host dưới dạng popup / redirect / embed.
- [Tham chiếu API → Refunds](https://docs.infraio.xyz/vi/api-reference#refunds) — catalog endpoint (mint, submit, renewal, status).
- [Khái niệm → Đơn hàng](https://docs.infraio.xyz/vi/concepts/orders) — trạng thái Refund gắn lại vào vòng đời Order như thế nào.
- [Webhooks → Tổng quan](https://docs.infraio.xyz/vi/webhooks/overview) — catalog event đầy đủ.
