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

# 退款

**Refund** 是一等实体,不是 Order 上的一个 flag。你可以发起部分退款、
对同一个 Order 多次退款,或在同一个流程内退款 + 再次扣款。

> **Note:**
>
> 退款也可以在[商户应用](https://docs.infraio.xyz/zh-CN/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`。 |

---

## 商户发起

你决定退款(例如买家在聊天里投诉)。调用商户发起端点 — 它跳过审批,
直接落到 `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 自动路由)。

订单在你执行链上转账之前(见
[执行加密退款](#执行加密退款))保留其现有状态。

---

## 客户发起 — 退款申请 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 | "此链接已被替换 — 请检查邮件获取新链接"(新 token **不会**在此显示,以防止链接转发攻击) |
| `CANCELED` | 商户从仪表板吊销了 token | 纯文本"此退款申请已取消" |

> **Note:**
>
> Token 一次性。一旦 `SUBMITTED`,URL 仍然有效,买家可继续查看状态,
> 但不能再用来提交。要对同一订单发起第二笔退款,请铸造新 token。

### TTL 默认值

| 铸造来源 | 默认 TTL | 原因 |
| --- | --- | --- |
| `POST /b2b/v1/merchants/{merchant_id}/refund-requests` (HMAC) | **30 分钟** | 程序化 — 假设立即递交给买家。 |
| 商户仪表板 | **24 小时** | 手动 — 商户把 URL 粘到邮件 / 短信里。 |

你可以用 body 中的 `ttl_seconds` 字段覆盖默认值。不强制最小或最大值;
常见取值在 1 分钟到 7 天之间。

### 通过 B2B API 铸造

适用于希望在支持对话或订单取消流程结束后,程序化生成退款链接的后端。

```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 风格的键值
  "hide_summary": false,                              // 可选 — 托管表单的 UI 标志
  "hide_header":  false
}
```

> **Warning:**
>
> B2B 请求签名是**原始小写 hex**,**没有 `sha256=` 前缀** — 那个前缀
> 只出现在*入站*的 webhook 签名(Infraio → 你的服务器)上。出站的 B2B
> 签名串是 `METHOD\nPATH\nTIMESTAMP\nBODY`;规范算法见
> [身份验证](https://docs.infraio.xyz/zh-CN/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 弹窗
提供一个切换:**Execute now** vs **Send link to customer**。后者会
创建一个退款申请 token(与上面的 B2B 调用相同),并把带复制按钮和二维码的
URL 显示给你。把它粘到合适的渠道 — 邮件、支持聊天、短信。

### 通过 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());

// 客户端:打开托管表单
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()`](https://docs.infraio.xyz/zh-CN/sdks/javascript#sdkopenrefundrequest-)。

### 客户续期 — 由买家驱动重发

如果买家在 token 过期后打开 URL,页面会提供一个**申请新链接**按钮代替表单。
点击后:

1. 发送续期申请(无需凭据,链接本身即授权)
2. 可选捕获买家留给你的自由文本备注(`customer_note`)
3. 把 token 移到 `RENEWAL_REQUESTED`,并向你的 Webhook 发出
   `refund_request.renewal_requested`

你的仪表板会在续期申请组件上展示一个角标。一键批准后,会签发一个
新的 `ACTIVE` token、发出 `refund_request.renewed`,并允许你复制新 URL
再次发送。旧 URL 仍可访问,但渲染"已替换 — 请检查邮件",这样旧 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-CN/concepts/chains))时,退款变为 `EXECUTED`,
订单的累计退款金额会更新。

> **Warning:**
>
> 我们刻意不托管商户资金,这意味着我们无法代你执行退款。请把链上
> 发送做进你自己的管理工具 — 从多签或热钱包发起
> `eth_sendRawTransaction`,并以把 tx hash 提交到退款 API 作为流程
> 的结尾。

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

流程相同:你从自己的钱包发出退款,然后提交交易哈希。细节随网络而异:

- 后台的退款页面会显示收款地址、金额、网络和代币,并在网络支持时附带二维码:Solana 上是 Solana Pay 二维码,TON 上是 TON 转账链接。TRON 上会显示可复制的收款地址(没有携带金额的钱包链接),因此金额需要你自己输入。
- `token_address` 是该网络上代币的地址:TRC-20 合约、SPL mint,或 Jetton master 地址。
- 交易哈希格式各不相同:TRON 是不带前缀的十六进制,Solana 是 base58 签名,TON 是十六进制或 base64 哈希。
- 平台会在链上校验这笔确切的交易,然后依据[链与资产](https://docs.infraio.xyz/zh-CN/concepts/chains)中的确认数把退款置为 `EXECUTED`。

---

## Webhook 事件

退款子系统发出两类事件:

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

| 事件 | 触发条件 |
| --- | --- |
| `refund_request.created` | 一个 token 被铸造 — `data.source` 是 `b2b` / `dashboard` / `renewal` |
| `refund_request.renewal_requested` | 买家在 token 过期后点击了"申请新链接"。**请订阅 — 这是商户行动的信号。** |
| `refund_request.renewed` | 你批准了续期,新 token 取代旧 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-CN/sdks/javascript#sdkopenrefundrequest-) — 以 popup / redirect / embed 打开托管退款表单。
- [API 参考 → 退款](https://docs.infraio.xyz/zh-CN/api-reference#退款) — 端点目录(铸造、提交、续期、状态)。
- [概念 → 订单](https://docs.infraio.xyz/zh-CN/concepts/orders) — Refund 状态如何反馈到 Order 生命周期。
- [Webhooks → 概览](https://docs.infraio.xyz/zh-CN/webhooks/overview) — 完整事件目录。
