关于 refunds-and-disputes
面向 DodoPayments 开发者的技能,涵盖退款、争议处理及付款撤销后的客户访问控制。它说明 API 操作、Webhook 处理、生命周期决策和权益核对。
主要功能
- 01创建全额及按商品退款
- 02列出并检索退款与争议
- 03解读退款和争议状态
- 04处理撤销相关 Webhook
- 05核对客户访问权限与权益
- 06收集并提交争议证据
适用场景
- 01为购买中的指定商品办理部分退款
- 02争议未结案时撤销访问权限
- 03争议胜诉或撤回后恢复访问
- 04退款成功后更新客户权益
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dodopayments/ skills --skill refunds-and-disputes安装后在 Claude Code 中输入/refunds-and-disputes确认已加载。
详细介绍
退款与争议
此技能介绍如何发起退款(全额和部分退款)、处理争议生命周期,以及在付款撤销期间管理客户访问权限。争议仅支持接收;Dodo 作为名义商户处理银行卡网络流程,而你负责控制应用访问权限和收集证据。
何时使用此技能
- 你需要为一笔付款发起退款(全额或部分退款)
- 客户对一笔扣款提出争议,你需要提交证据予以回应
- 你正在构建与退款或争议状态关联的访问权限撤销逻辑
- 你需要在付款撤销后重新核对客户权益
- 你正在生产环境中处理退款或争议 Webhook
核心概念
退款由你通过 API 发起。每笔退款都有一个状态,用于告知你款项是否已实际从账户中转出。部分退款针对原付款中的特定商品条目。
争议由客户的银行卡网络发起。你无法创建争议,只能列出和检索争议。争议生命周期包含七个事件,每个事件都要求应用采取不同的操作。
金额始终以货币的最小单位表示(例如,USD 的分、INR 的派萨等)。
撤销访问权限是指移除客户使用产品或服务的权限。争议仍处于开放状态时,通常应撤销访问权限。在 dispute.won 或 dispute.cancelled(争议已撤回,无需进一步操作)时恢复访问权限;对于失败结果(dispute.accepted、dispute.lost、dispute.expired),则继续保持访问权限已撤销的状态。
退款
创建退款
import DodoPayments from 'dodopayments';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment: 'test_mode',
});
// Full refund
const refund = await client.refunds.create({
payment_id: 'pay_abc123',
});
console.log(refund.refund_id);
console.log(refund.status); // 'pending', 'succeeded', 'review', or 'failed'
按商品条目部分退款
若只退还付款中的特定商品,请传入 items 数组,其中包含每个商品条目的 ID 和要退还的金额(以货币最小单位表示)。省略 amount 即可退还整个商品条目的金额:
const partialRefund = await client.refunds.create({
payment_id: 'pay_abc123',
items: [
{ item_id: 'item_1' }, // no amount: the whole item is refunded
{ item_id: 'item_2', amount: 2500 },
],
});
退款状态
每笔退款有以下四种状态之一:
| 状态 | 含义 | 你的操作 |
|---|---|---|
pending | 退款正在处理中;款项尚未从账户中转出 | 等待 Webhook,或轮询退款状态 |
succeeded | 款项已退还给客户 | 如果产品不可退款,则撤销访问权限;更新记录 |
review | 退款正在审核(较少见) | 联系支持团队;不要假设退款一定会成功 |
failed | 退款失败;款项仍在账户中 | 调查失败原因;考虑重试或联系客户 |
列出和检索退款
// List all refunds
const refunds = await client.refunds.list();
// Retrieve a specific refund
const refund = await client.refunds.retrieve('ref_xyz789');
console.log(refund.status);
处理退款 Webhook
Webhook 签名验证请参阅 webhook-integration 技能。处理前务必验证签名。
import DodoPayments from 'dodopayments';
import express from 'express';
const app = express();
app.use(express.raw({ type: 'application/json' }));
// `environment` is a narrow union, but env vars are `string | undefined`.
// Narrow explicitly rather than casting, and default to test mode so a missing
// variable can never accidentally hit live.
const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment,
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
});
type WebhookEvent = ReturnType<typeof client.webhooks.unwrap>;
// Register ONE endpoint for every Dodo event. A second app.post('/webhook')
// would never run, because the first handler answers every request.
app.post('/webhook', async (req, res) => {
let event: WebhookEvent;
try {
event = client.webhooks.unwrap(req.body.toString(), {
headers: {
'webhook-id': req.headers['webhook-id'] as string,
'webhook-signature': req.headers['webhook-signature'] as string,
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
},
});
} catch {
// Only signature verification failures reach this branch
res.status(401).json({ error: 'Invalid signature' });
return;
}
try {
await handleRefundEvent(event);
await handleDisputeEvent(event); // defined under "Handle dispute webhooks"
res.json({ received: true });
} catch (error) {
// Any non-2xx response makes Dodo retry the delivery
console.error('Webhook handler failed:', error);
res.status(500).json({ error: 'Webhook handler failed' });
}
});
async function handleRefundEvent(event: WebhookEvent) {
if (event.type === 'refund.succeeded') {
const refund = event.data;
if (!refund.is_partial) {
// Full refund: nothing in the payment is still paid for
await revokeCustomerAccess(refund.customer.customer_id);
} else {
// Partial refund: keep access to items that are still paid for, and
// remove it for any item with nothing left to refund. Reading the line
// items covers every case - an omitted amount, an explicit amount equal
// to the item price, or several partial refunds that add up to it.
const { items } = await client.payments.retrieveLineItems(refund.payment_id);
const fullyRefundedItemIds = items
.filter((item) => item.refundable_amount === 0)
.map((item) => item.items_id);
await revokeItemAccess(refund.customer.customer_id, fullyRefundedItemIds);
}
await updateRefundRecord(refund.refund_id, 'succeeded');
}
if (event.type === 'refund.failed') {
const refund = event.data;
// Refund failed; keep access active, alert support
await logRefundFailure(refund.refund_id, refund.reason);
}
}
争议
检索争议
争议仅支持接收。你无法创建争议,只能列出和检索争议。
// List all disputes
const disputes = await client.disputes.list();
// Retrieve a specific dispute
const dispute = await client.disputes.retrieve('dis_abc123');
console.log(dispute.dispute_status);
console.log(dispute.amount); // in smallest currency unit
争议生命周期
争议会经历七个事件。每个事件都要求应用采取不同的操作:
| 事件 | 含义 | 你的操作 |
|---|---|---|
dispute.opened | 客户发起了拒付 | 记录争议;考虑立即撤销访问权限;从日志中收集证据 |
dispute.challenged | 你已提交证据 | 等待银行卡网络审核 |
dispute.accepted | 你接受(认输)了争议;款项将转给持卡人 | 保持访问权限已撤销;在记录中将争议标记为已接受 |
dispute.cancelled | 争议已撤回或取消 | 无需进一步操作;恢复你在 dispute.opened 时撤销的访问权限 |
dispute.expired | 争议期限已结束,但尚未解决 | 按争议败诉处理;保持访问权限已撤销 |
dispute.won | 你赢得了争议 | 保留款项;恢复访问权限;更新客户记录 |
dispute.lost | 你输掉了争议 | 款项已退还给持卡人;保持访问权限已撤销;重新核对记录 |
处理争议 Webhook
争议 Webhook 负载中不会包含 customer 字段,这是有意为之。你需要额外调用一次 disputes.retrieve() 来获取客户信息:Webhook 中的 dispute_id 用于标识争议,而返回的 GetDispute 包含 customer。如果不进行此查询,访问控制处理程序收到的值将是 undefined。
async function resolveDisputeCustomerId(disputeId: string) {
const dispute = await client.disputes.retrieve(disputeId);
return dispute.customer.customer_id;
}
// Called from the single /webhook handler above, after signature verification
async function handleDisputeEvent(event: WebhookEvent) {
if (event.type === 'dispute.opened') {
const dispute = event.data;
// Record the dispute and revoke access
await recordDispute(dispute.dispute_id, dispute.payment_id, dispute.amount);
const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
await revokeCustomerAccess(customerId);
// Gather evidence from your system and submit via dashboard
// (no evidence-submission API exists; use the Dodo dashboard)
}
if (event.type === 'dispute.won') {
const dispute = event.data;
// Funds retained; restore normal state
await markDisputeResolved(dispute.dispute_id, 'won');
const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
await restoreCustomerAccess(customerId);
}
if (event.type === 'dispute.lost') {
const dispute = event.data;
// Funds returned to cardholder; keep access revoked
await markDisputeResolved(dispute.dispute_id, 'lost');
// Do NOT restore access
}
if (event.type === 'dispute.accepted') {
const dispute = event.data;
// Merchant conceded; funds go to the cardholder and access stays revoked
await markDisputeResolved(dispute.dispute_id, 'accepted');
// Do NOT restore access
}
if (event.type === 'dispute.cancelled') {
const dispute = event.data;
// The dispute was withdrawn; no further action is needed, so undo the
// revocation from dispute.opened
await markDisputeResolved(dispute.dispute_id, 'cancelled');
const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
await restoreCustomerAccess(customerId);
}
}
提交证据
Dodo 以名义商家的身份处理银行卡网络的争议流程。你需要通过 Dodo 控制面板提交证据,而不是通过 API。目前没有提交证据的 API。
争议开启后,请收集证据(订单确认、送达证明、客户沟通记录等),并在争议期限内(自争议创建之日起 10 天)上传到控制面板。银行卡网络会审核证据并作出最终决定。
撤销访问权限的模式
争议期间管理访问权限的一种常见模式:
async function handleDisputeLifecycle(
dispute: Awaited<ReturnType<typeof client.disputes.retrieve>>,
) {
const customerId = dispute.customer.customer_id;
switch (dispute.dispute_status) {
case 'dispute_opened':
// Revoke access immediately
await revokeCustomerAccess(customerId);
break;
case 'dispute_won':
// You won; restore access
await restoreCustomerAccess(customerId);
break;
case 'dispute_lost':
// You lost; keep access revoked
// (do nothing)
break;
case 'dispute_accepted':
// You conceded; funds go to the cardholder and access stays revoked
break;
case 'dispute_cancelled':
// Withdrawn; no further action needed, so restore access
await restoreCustomerAccess(customerId);
break;
case 'dispute_expired':
// Treat as lost; keep access revoked
break;
case 'dispute_challenged':
// Evidence is under review; keep access revoked
break;
}
}
退款后核对权益
退款成功后,你必须核对客户的访问权限。如果产品不可退款(例如数字下载内容或已使用的订阅),则撤销访问权限。如果产品可退款(例如尚未开始的订阅),你可以根据自己的政策恢复访问权限,也可以继续保持撤销状态。
async function reconcileRefund(refund) {
if (refund.status !== 'succeeded') {
return; // Not yet final
}
const payment = await client.payments.retrieve(refund.payment_id);
const customer = payment.customer;
// Revoke access for non-refundable products
const includesNonRefundableProduct = payment.product_cart?.some(({ product_id }) =>
isNonRefundable(product_id),
);
if (includesNonRefundableProduct) {
await revokeCustomerAccess(customer.customer_id);
}
// Update your entitlement records
await updateEntitlementRecord(customer.customer_id, {
refund_id: refund.refund_id,
refund_amount: refund.amount,
refund_date: new Date(),
});
}
常见错误
自动退款但不撤销访问权限。 退款 Webhook 表示款项将从你的账户中退回。如果产品不可退款,请立即撤销访问权限。不要等客户提出要求。
把 dispute.opened 当作最终结果。 只有银行卡网络作出裁决后,争议才算败诉。争议未解决期间应继续撤销访问权限,但不要删除客户数据或关闭其账户。
计算权益时忽略部分退款。 如果客户仅退回多件商品中的一件,其对其他商品的权益仍然有效。应按商品跟踪退款,而不仅仅按付款跟踪。
败诉后恢复访问权限。 dispute.accepted、dispute.lost 和 dispute.expired 表示争议已解决(或通常会解决),且结果有利于持卡人;应继续撤销访问权限。只有在 dispute.won 或 dispute.cancelled 时才恢复访问权限(争议已撤回,无需进一步处理)。
在争议期限结束后提交证据。 你有 10 天时间,从争议创建之日起算,来作出回应。设置日历提醒,并在争议开启后立即收集证据。
认为 Dodo 会负责撤销访问权限。 Dodo 负责处理银行卡网络的流程;应用访问权限由你负责。Dodo 不会自动撤销客户的订阅或文件访问权限。
资源
- 退款 API
- 退款功能指南
- 争议 Webhook
- 用于签名验证的
webhook-integration技能