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

# 退款

**Refund** 是一級實體，而非 Order 上的旗標。你可以發起部分退款、
針對同一個訂單發起多筆退款，或在同一流程中退款 + 重新收款。

> **Note:**
>
> 也可以從[商家 App](https://docs.infraio.xyz/zh-TW/get-started/merchant-app) 發起退款。

退款紀錄有兩種產生方式:

| 流程 | 由誰填表單 | 驗證 | 落點 |
| --- | --- | --- | --- |
| 商家發起 | 你的儀表板 / 你的後端 | HMAC(sk_…) | 立即落在 `APPROVED` |
| 客戶發起 | 買家，在我們的託管頁 | 一次性 token(無憑證) | `PENDING` — 你核准，或若你的設定為自動核准則直接放行 |

客戶發起的流程使用短效的**退款申請 token**。你鑄造 token(B2B 或
儀表板),用任何你喜歡的方式把 URL 交給買家，買家就在
`checkout.infraio.xyz/refund-request/:token` 完成退款細節。買家
永遠不會接觸你的 API,也看不到你的商家金鑰。

## 退款生命週期

```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 --> [*]
```

| 狀態 | 意義 |
| --- | --- |
| `PENDING` | 退款已記錄，等待核准。客戶發起的退款一律從這裡開始。 |
| `APPROVED` | 已可執行。商家發起的退款直接跳到這裡。 |
| `REJECTED` | 退款被駁回。訂單狀態不變。 |
| `EXECUTED` | 鏈上轉帳已確認。訂單轉為 `PARTIALLY_REFUNDED` / `REFUNDED`。 |

---

## 商家發起

你決定退款(例如買家在 chat 上抱怨)。呼叫商家發起的 endpoint —
它會跳過審查並立即落在 `APPROVED`。

```http
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",        // 網路 slug;參見 概念 → 鏈
  "refund_token_address":"0xUSDC_CONTRACT" // 退回的 ERC-20 合約;通常是原始 token
}
```

退款請求上沒有 `currency` 欄位 — 退款一律繼承訂單的顯示幣別(目前
為 USD)。三元組 `(refund_to_address, refund_network, refund_token_address)`
是鏈上目的地。法幣管道
會忽略它們(由 provider 自動路由)。

訂單會維持既有狀態，直到你執行鏈上轉帳(見[執行加密貨幣退款](#executing-a-crypto-refund))。

---

## 客戶發起 — 退款申請 token

買家**在我們的託管頁面**填寫退款表單，而非你的頁面。你的工作只是
鑄造 token 並交付 URL。

### 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 --> [*]
```

| 狀態 | 意義 | 客戶 URL 呈現 |
| --- | --- | --- |
| `ACTIVE` | Token 有效，`now < expires_at` | 退款表單(`refund_to_address`、`reason`、`amount`、選填備註 → `metadata.note`) |
| `SUBMITTED` | 買家完成表單;退款紀錄已存在 | 狀態卡，鏡像 `/r/:linkToken` |
| `EXPIRED_UNUSED` | TTL 在買家送出前過期 | 提示「此連結已過期。請申請新的連結」 |
| `RENEWAL_REQUESTED` | 買家申請了新連結 | 等待通知:「已通知你的商家」 |
| `RENEWED` | 商家核准續期並鑄造替代 token | 「此連結已被替換 — 請查看 email 中的新連結」(新 token **不會**在此處顯示，以防止連結轉發攻擊) |
| `CANCELED` | 商家從儀表板撤銷 token | 純訊息「此退款申請已被取消」 |

> **Note:**
>
> Token 為單次使用。一旦 `SUBMITTED`,URL 仍對買家有效以便查看狀態，
> 但無法再次用來送出。如要對同一訂單發第二筆退款，請鑄造新 token。

### TTL 預設

| 鑄造來源 | 預設 TTL | 原因 |
| --- | --- | --- |
| `POST /b2b/v1/merchants/{merchant_id}/refund-requests`(HMAC) | **30 分鐘** | 程式化 — 預期會立即交給買家。 |
| 商家儀表板 | **24 小時** | 人工 — 商家把 URL 貼到 email / SMS。 |

你可以用 body 中的 `ttl_seconds` 欄位覆寫預設值。系統不強制
最小或最大上下限;常見值為 1 分鐘到 7 天。

### 透過 B2B API 鑄造

對於想在 support 對話、訂單取消流程等之後以程式產生退款連結的後端。

```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",                          // 必填: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 風格 key/value
  "hide_summary": false,                              // 託管表單的選填 UI 旗標
  "hide_header":  false
}
```

> **Warning:**
>
> B2B 請求簽章是**原始小寫 hex**,**沒有 `sha256=` 前綴** — 那個前綴
> 只出現在*入站*的 webhook 簽章(Infraio → 你的伺服器)上。出站的 B2B
> 簽章字串是 `METHOD\nPATH\nTIMESTAMP\nBODY`;規範演算法見
> [身分驗證](https://docs.infraio.xyz/zh-TW/api-reference/authentication)。

金額**在**鑄造 body 內，而且**必填**。它鎖定買家在表單上可以提交
的上限 — 買家可以提交更少但不能更多。(部分退款請以該金額鑄造
token;全額退款請以訂單總額鑄造。)

較舊的 `{ "order_id": "..." }` 結構仍可接受，並視為
`ref_type=order_id`,但新整合請使用明確的 `ref_type` + `ref_value` 對。

回應:

```json
{
  "token":      "rfqt_01J7P3Q9R…",
  "refund_url": "https://checkout.infraio.xyz/refund-request/rfqt_01J7P3Q9R…",
  "expires_at": "2026-05-28T10:32:00Z"
}
```

會對你的 webhook 端點發出 `refund_request.created`(讓你可以記錄
/ 稽核某訂單目前哪個 token 有效)。

### 透過儀表板鑄造

[商家儀表板](https://app.infraio.xyz)的 Issue Refund modal
提供切換:**Execute now** vs **Send link to customer**。選擇後者
會建立一個 refund-request token(與上面的 B2B 呼叫相同),然後顯示 URL 加上複製
按鈕與 QR code。把它貼到任何適合的通道 — email、support chat、SMS。

### 透過 JavaScript SDK — `openRefundRequest`

如果你的技術棧中已經有 `@lartech/infraio-checkout-js`,且希望買家
在你自己的頁面流程內完成退款(而非透過外部 URL),請把 B2B 鑄造
與 `sdk.openRefundRequest()` 搭配使用:

```ts
// 伺服端:鑄造 token
const { token } = await fetch("/api/mint-refund-token", { method: "POST" }).then(r => r.json());

// Client 端:開啟託管表單
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: () => { /* 買家關閉了 popup */ },
  onError:  (err) => { /* 詳見 SDK 參考 */ },
});
```

完整的選項表請見 [SDK 參考 → `sdk.openRefundRequest()`](https://docs.infraio.xyz/zh-TW/sdks/javascript#sdkopenrefundrequest-)。

### 客戶續期 — 買家驅動的重發

若買家在 token 過期後打開 URL,頁面會提供 **Request new link** 按鈕
取代表單。點擊後:

1. 送出續期申請(無需憑證;連結本身即為授權)
2. 選擇性擷取買家想留給你的自由文字備註(`customer_note`)
3. 把 token 移到 `RENEWAL_REQUESTED` 並對你的 webhook 發出
   `refund_request.renewal_requested`

你的儀表板會在 renewal-requests widget 上顯示徽章。一鍵核准後，
會發出新的 `ACTIVE` token、發出 `refund_request.renewed`,
讓你複製新 URL 再寄一次。舊 URL 仍可存取，但會顯示
「Replaced — check your email」,讓被轉發的舊 URL 無法用來釣出
新的。

---

## 執行加密貨幣退款

API 只記錄意圖 — 它不移動資金。**你**從資金庫錢包簽章並廣播鏈上
轉帳，然後把 tx hash 蓋回退款紀錄:

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

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

Body 內三個欄位都必填:同一筆 tx hash 可能存在於不同鏈上，而且
你可能以與原始付款不同的穩定幣退款。

當 InfraIO Pay 看到那筆交易達到所需的確認數(見[支援的鏈與資產](https://docs.infraio.xyz/zh-TW/concepts/chains))
時，退款會轉為 `EXECUTED`,訂單的退款總額也會更新。

> **Warning:**
>
> 我們刻意不託管商家資金，因此無法代你執行退款。請把鏈上送出整合到
> 你的 admin 工具中 — 從 multisig 或 hot wallet 做
> `eth_sendRawTransaction`,並讓流程結束於把 tx hash 提交到退款 API。

### TRON、Solana 與 TON 上的退款

流程相同:你從自己的錢包送出退款,然後提交交易雜湊。細節依網路而異:

- 後台的退款畫面會顯示收款位址、金額、網路與代幣,並在網路支援時附上 QR code:Solana 上是 Solana Pay QR code,TON 上是 TON 轉帳連結。TRON 上會顯示可複製的收款位址(沒有可帶入金額的錢包連結),因此金額需由你自行輸入。
- `token_address` 是該網路上代幣的位址:TRC-20 合約、SPL mint,或 Jetton master 位址。
- 交易雜湊格式各不相同:TRON 是不帶前綴的十六進位,Solana 是 base58 簽章,TON 是十六進位或 base64 雜湊。
- 平台會在鏈上驗證這筆確切的交易,然後依據[支援的鏈與資產](https://docs.infraio.xyz/zh-TW/concepts/chains)中的確認數把退款轉為 `EXECUTED`。

---

## Webhook 事件

退款子系統會發出兩個事件家族:

### Token 生命週期(`refund_request.*`)

| 事件 | 觸發時機 |
| --- | --- |
| `refund_request.created` | Token 被鑄造 — `data.source` 為 `b2b` / `dashboard` / `renewal` |
| `refund_request.renewal_requested` | 買家在 token 過期後點「Request new link」。**請訂閱此事件 — 它是商家行動的觸發點。** |
| `refund_request.renewed` | 你核准續期，新 token 取代舊的。`data.old_token` / `data.new_token` 形成稽核鏈。 |
| `refund_request.canceled` | 你從儀表板把 token 翻為 `CANCELED`。冪等 — 只有第一次轉換會發出。`data.reason` 是商家選填備註。 |

### 退款生命週期(`payment.refund.*`)

| 事件 | 觸發時機 |
| --- | --- |
| `payment.refund.requested` | 一筆新的 Refund 列存在 — 任一來源(表單送出、商家發起的 API、儀表板)。 |
| `payment.refund.approved` | 退款被核准 — 可能是自動核准(商家發起),或在待審筆上呼叫 `/approve` 之後。 |
| `payment.refund.rejected` | 你對待審退款呼叫了 `/reject`。 |
| `payment.refund.executed` | 資金已轉移(你的加密貨幣 tx hash 達到要求的確認數)。 |

`payment.failed` **不會**對退款觸發 — 退款有自己的事件序列，前綴為
`payment.refund.*`。

## 下一步

- [SDK 參考 → `sdk.openRefundRequest()`](https://docs.infraio.xyz/zh-TW/sdks/javascript#sdkopenrefundrequest-) — 以 popup / redirect / embed 開啟託管退款表單。
- [API 參考 → Refunds](https://docs.infraio.xyz/zh-TW/api-reference#退款) — endpoint 目錄(鑄造、提交、續期、狀態)。
- [概念 → 訂單](https://docs.infraio.xyz/zh-TW/concepts/orders) — Refund 狀態如何串回 Order 生命週期。
- [Webhooks → 總覽](https://docs.infraio.xyz/zh-TW/webhooks/overview) — 完整事件目錄。
