mcpField
R
refunds-and-disputes

refunds-and-disputes

github.com/dodopayments其他13197 次安装3 周前更新
代码仓库

关于 refunds-and-disputes

面向 DodoPayments 开发者的技能,涵盖退款、争议处理及付款撤销后的客户访问控制。它说明 API 操作、Webhook 处理、生命周期决策和权益核对。

主要功能

  1. 01创建全额及按商品退款
  2. 02列出并检索退款与争议
  3. 03解读退款和争议状态
  4. 04处理撤销相关 Webhook
  5. 05核对客户访问权限与权益
  6. 06收集并提交争议证据

适用场景

  1. 01为购买中的指定商品办理部分退款
  2. 02争议未结案时撤销访问权限
  3. 03争议胜诉或撤回后恢复访问
  4. 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 不会自动撤销客户的订阅或文件访问权限。

资源

评论

支持 Markdown