退款
Refund 是一等实体,不是 Order 上的一个 flag。你可以发起部分退款、 对同一个 Order 多次退款,或在同一个流程内退款 + 再次扣款。
退款记录可以由两条路径产生:
| 流程 | 谁来填表 | 鉴权 | 落点 |
|---|---|---|---|
| 商户发起 | 你的仪表板 / 你的后端 | HMAC (sk_…) | 立即落到 APPROVED |
| 客户发起 | 买家,在我们托管页 | 一次性 token(无凭据) | PENDING — 由你批准,或在你的配置自动批准时直接通过 |
客户发起的流程使用一个短期的退款申请 token。你铸造 token(B2B 或
仪表板),按你喜欢的方式把 URL 交给买家,买家在
checkout.infraio.xyz/refund-request/:token 上完成退款细节。买家从不
触达你的 API,也从不看到你的商户密钥。
退款生命周期
| 状态 | 含义 |
|---|---|
PENDING | 退款已记录,等待审批。客户发起的退款总是从这里起步。 |
APPROVED | 已放行执行。商户发起的退款直接跳到这里。 |
REJECTED | 退款被拒。订单状态不变。 |
EXECUTED | 链上转账已确认。订单转为 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", // 网络 slug;见 概念 → 链
"refund_token_address":"0xUSDC_CONTRACT" // 用于退款的 ERC-20 合约;通常是原 token
}退款请求上没有 currency 字段 — 退款总是继承订单的显示币种
(目前是 USD)。(refund_to_address, refund_network, refund_token_address)
三元组是链上目的地;payment-service 用它驱动加密 saga。在法币路径中
它们被忽略(由 provider 自动路由)。
订单在你执行链上转账之前(见 执行加密退款)保留其现有状态。
客户发起 — 退款申请 token
买家在我们托管页填退款表单,不在你那里。你唯一要做的就是铸造 token 并把 URL 交付出去。
Token 生命周期
| 状态 | 含义 | 客户访问 URL 时显示 |
|---|---|---|
ACTIVE | Token 有效,now < expires_at | 退款表单(refund_to_address、reason、amount、可选备注 → metadata.note) |
SUBMITTED | 买家已提交表单;存在一条 Refund 行 | 状态卡片,镜像 /r/:linkToken |
EXPIRED_UNUSED | TTL 在买家提交前已过 | 提示:“此链接已过期。申请一个新的” |
RENEWAL_REQUESTED | 买家申请了新链接 | 等待提示:“已通知你的商户” |
RENEWED | 商户批准续期并铸造了替代 token | ”此链接已被替换 — 请检查邮件获取新链接”(新 token 不会在此显示,以防止链接转发攻击) |
CANCELED | 商户从仪表板吊销了 token | 纯文本”此退款申请已取消” |
Token 一次性。一旦 SUBMITTED,URL 仍然有效,买家可继续查看状态,
但不能再用来提交。要对同一订单发起第二笔退款,请铸造新 token。
TTL 默认值
| 铸造来源 | 默认 TTL | 原因 |
|---|---|---|
POST /b2b/v1/merchants/{merchant_id}/refund-requests (HMAC) | 30 分钟 | 程序化 — 假设立即递交给买家。 |
POST /payment/v1/merchants/{merchant_id}/refund-requests(仪表板 JWT) | 24 小时 | 手动 — 商户把 URL 粘到邮件 / 短信里。 |
两条端点都接受 body 中的 ttl_seconds 字段以覆盖默认值。目前没有
服务端强制的硬性 min/max 边界 — 常见取值在 1 分钟到 7 天之间。
请保持在这个范围内,以免给买家带来意外或让已废弃的 token 长期占用容量。
通过 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= 前缀 — 那个前缀
只出现在入站的 webhook 签名(Infraio → 你的服务器)上。出站的 B2B
签名串是 METHOD\nPATH\nTIMESTAMP\nBODY;规范算法见
身份验证。
金额在铸造 body 中,而且必填。它锁定买家在表单上可提交的 上限 — 他们可以提交更少,但绝不能更多。(部分退款:用部分金额铸造 token;全额退款:用订单总额铸造。)
为兼容老客户端,旧的 { "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"
}向你注册的 Webhook 端点发出 refund_request.created(便于记录 / 审计
当前对某订单活跃的是哪个 token)。
通过仪表板铸造
商户仪表板 上的 Issue Refund 弹窗
提供一个切换:Execute now vs Send link to customer。后者会
在后台调用 POST /payment/v1/merchants/{merchant_id}/refund-requests
(JWT 鉴权,body 形态与上面的 B2B 相同),并把带复制按钮和二维码的
URL 显示给你。把它粘到合适的渠道 — 邮件、支持聊天、短信。
通过 JavaScript SDK — openRefundRequest
如果你的技术栈里已经有 @lartech/infraio-checkout-js,且希望买家在
你自己的页面流程内完成退款(而非外部 URL),把 B2B 铸造与
sdk.openRefundRequest() 配对使用:
// 服务端:铸造 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()。
客户续期 — 由买家驱动重发
如果买家在 token 过期后打开 URL,页面会提供一个申请新链接按钮代替表单。 点击后:
- POST 到
/pub/v1/refund-requests/:token/request-renewal(无凭据 — token 本身就是真相载体) - 可选捕获买家留给商户的自由文本备注(
customer_note) - 把 token 移到
RENEWAL_REQUESTED,并向你的 Webhook 发出refund_request.renewal_requested
你的仪表板会在续期申请组件上展示一个角标。一键批准后,系统会铸造一个
新的 ACTIVE token、发出 refund_request.renewed,并允许你复制新 URL
再次发送。旧 URL 仍可访问,但渲染”已替换 — 请检查邮件”,这样旧 URL
的转发副本就无法被用来钓出新的。
执行加密退款
API 记录意图 — 它不移动资金。你从商户钱包签名并广播链上 转账,然后把 tx hash 标记回退款记录:
POST /b2b/v1/refunds/:refund_id/submit-tx
Content-Type: application/json
{
"tx_hash": "0xabcd…",
"network": "ethereum",
"token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
}三个 body 字段都必填:同一个 tx hash 可在不同链上存在,而且你可以 用与原支付不同的稳定币退款。
当链上观察者看到该 tx 达到配置的确认数(见
链与资产)时,退款翻转为 EXECUTED,
订单的累计退款金额会更新。
我们刻意不托管商户资金,这意味着我们无法代你执行退款。请把链上
发送做进你自己的管理工具 — 从多签或热钱包发起
eth_sendRawTransaction,并以把 tx hash 提交到退款 API 作为流程
的结尾。
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()— 以 popup / redirect / embed 打开托管退款表单。 - API 参考 → 退款 — 端点目录(铸造、提交、续期、状态)。
- 概念 → 订单 — Refund 状态如何反馈到 Order 生命周期。
- Webhooks → 概览 — 完整事件目录。