mcpField
P
payment-idempotency

payment-idempotency

github.com/vtex开发471.1k 次安装2 周前更新
代码仓库

关于 payment-idempotency

面向 VTEX Payment Provider Protocol 支付处理程序的技能,规范幂等性、持久化状态和合法状态转换。重点是重试时复用已存结果,避免重复扣款或错误处理异步支付。

主要功能

  1. 01Create Payment 使用 paymentId 幂等
  2. 02操作使用 requestId 去重
  3. 03重复请求返回已存响应
  4. 04异步支付返回 undefined 状态
  5. 05持久化支付状态与状态机

适用场景

  1. 01实现支付创建与重试处理
  2. 02防止重复取消、捕获或退款
  3. 03处理 Boleto 与 Pix 等异步支付
  4. 04验证支付状态转换规则

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/vtex/skills --skill payment-idempotency

安装后在 Claude Code 中输入/payment-idempotency确认已加载。

详细介绍

幂等性与重复预防

适用场景

在以下场景中使用此技能:

  • 实现处理付款、取消、捕获或退款的任何 PPP 端点处理程序
  • 确保使用相同标识符重复调用网关时,返回相同结果且不会重新处理
  • 构建付款状态机,防止无效状态转换(例如,捕获已取消的付款)
  • 处理网关针对状态为 undefined 的付款提供的 7 天重试窗口

以下场景不使用此技能:

  • PPP 端点的响应格式和 HTTP 方法 — 使用 payment-provider-protocol
  • 异步回调 URL 通知逻辑 — 使用 payment-async-flow
  • PCI 合规性和安全代理 — 使用 payment-pci-security
  • POS / 销售应用终端付款(Venda Direta Credito、Venda Direta Debito,以及 Wait for confirmation 轮询期间重叠的 Create Payment)— 将此技能与 payment-ppp-pos 一起使用

决策规则

  • 对 Create Payment 使用 paymentId 作为幂等键 — 每次使用相同 paymentId 的调用都必须返回相同结果。
  • 对 Cancel、Capture 和 Refund 操作使用 requestId 作为幂等键。
  • 如果网关使用相同的 paymentId 再次发送 Create Payment,返回已存储的响应,不要再次调用收单机构。这些重试可能会重叠;采用最后写入胜出的存储方式可能会丢弃状态更新 — 使用 etag / ifMatch 持久化(POS 相关扩展请参阅 payment-ppp-pos)。
  • 异步付款方式(Boleto、Pix)必须返回 status: "undefined" — 收单机构确认之前,绝不能返回 "approved"。
  • 付款只能按定义的状态流转:undefined → approved → settled,或 undefined → denied,或 approved → cancelled。只允许有效的状态转换。
  • 使用持久化数据存储(PostgreSQL、DynamoDB、VTEX IO 的 VBase)— 绝不要使用重启后会丢失的数据内存存储。

硬性约束

约束:必须将 paymentId 用作 Create Payment 的幂等键

连接器在处理新付款之前,必须检查是否存在给定 paymentId 对应的记录。如果存在记录,则返回已存储的响应,不要再次调用收单机构。

重要原因 对于状态为 undefined 的 Create Payment 请求,VTEX 网关最多会重试 7 天。如果没有使用 paymentId 实现幂等性,每次重试都会在收单机构处创建一笔新扣款,导致向客户重复收费。这会造成财务损失,并构成严重的生产事故。

检测 如果 Create Payment 处理程序在处理之前没有检查是否存在对应的 paymentId,请停止。处理程序必须先查询数据存储中的 paymentId。

正确

async function createPaymentHandler(req: Request, res: Response): Promise<void> {
  const { paymentId } = req.body;

  // Check for existing payment — idempotency guard
  const existingPayment = await paymentStore.findByPaymentId(paymentId);
  if (existingPayment) {
    // Return the exact same response — no new acquirer call
    res.status(200).json(existingPayment.response);
    return;
  }

  // First time seeing this paymentId — process with acquirer
  const result = await acquirer.authorize(req.body);

  const response = {
    paymentId,
    status: result.status,
    authorizationId: result.authorizationId ?? null,
    nsu: result.nsu ?? null,
    tid: result.tid ?? null,
    acquirer: "MyProvider",
    code: result.code ?? null,
    message: result.message ?? null,
    delayToAutoSettle: 21600,
    delayToAutoSettleAfterAntifraud: 1800,
    delayToCancel: 21600,
  };

  // Store the response for future idempotent lookups
  await paymentStore.save(paymentId, { request: req.body, response });

  res.status(200).json(response);
}

错误

async function createPaymentHandler(req: Request, res: Response): Promise<void> {
  const { paymentId } = req.body;

  // No idempotency check — every call hits the acquirer
  // If the Gateway retries this (which it will for undefined status),
  // the customer gets charged multiple times
  const result = await acquirer.authorize(req.body);

  res.status(200).json({
    paymentId,
    status: result.status,
    authorizationId: result.authorizationId ?? null,
    nsu: result.nsu ?? null,
    tid: result.tid ?? null,
    acquirer: "MyProvider",
    code: null,
    message: null,
    delayToAutoSettle: 21600,
    delayToAutoSettleAfterAntifraud: 1800,
    delayToCancel: 21600,
  });
}
约束:必须为重复请求返回完全相同的响应

当连接器收到的 Create Payment 请求中的 paymentId 已存在于数据存储中时,必须返回已存储的原始响应。不得创建新记录、生成新标识符或重新处理付款。

重要原因 网关使用响应字段(authorizationId、tid、nsu、status)跟踪交易。如果重试返回不同的值,网关就会丢失对原始交易的追踪,导致对账失败,并可能造成重复结算。

检测 如果处理程序发现已有 paymentId 后仍创建新的数据库记录或生成新的标识符,请停止。处理程序必须原样返回之前存储的响应。

正确

async function createPaymentHandler(req: Request, res: Response): Promise<void> {
  const { paymentId } = req.body;

  const existing = await paymentStore.findByPaymentId(paymentId);
  if (existing) {
    // Return the EXACT stored response — same authorizationId, tid, nsu, status
    res.status(200).json(existing.response);
    return;
  }

  // ... process new payment and store response
}

错误

async function createPaymentHandler(req: Request, res: Response): Promise<void> {
  const { paymentId } = req.body;

  const existing = await paymentStore.findByPaymentId(paymentId);
  if (existing) {
    // WRONG: Generating new identifiers for an existing payment
    // The Gateway will see different tid/nsu and lose track of the transaction
    const newTid = generateNewTid();
    res.status(200).json({
      ...existing.response,
      tid: newTid,  // Different from original — breaks reconciliation
      nsu: generateNewNsu(),
    });
    return;
  }

  // ... process new payment
}
约束:绝不能同步批准异步支付

如果支付方式是异步的(例如 Boleto、Pix、银行重定向),创建支付的响应必须返回 status: "undefined"。在收单机构实际确认或拒绝支付之前,绝不能返回 status: "approved" 或 status: "denied"。

为什么这很重要 对于异步支付方式,返回 approved 会让网关误以为支付已确认,而客户实际上还没有付款。订单发货了,却没有收到款项。商家既损失了商品,也损失了收入。正确的流程是返回 undefined,并使用 callbackUrl 在支付确认后通知网关。

检测 如果创建支付处理程序对异步支付方式(Boleto、Pix、银行转账、基于重定向的支付方式)返回 status: "approved" 或 status: "denied",请立即停止。异步支付方式必须返回 "undefined" 并使用回调。

正确

async function createPaymentHandler(req: Request, res: Response): Promise<void> {
  const { paymentId, paymentMethod, callbackUrl } = req.body;

  const isAsyncMethod = ["BankInvoice", "Pix"].includes(paymentMethod);

  if (isAsyncMethod) {
    const pending = await acquirer.initiateAsyncPayment(req.body);

    await paymentStore.save(paymentId, {
      status: "undefined",
      callbackUrl,
      acquirerRef: pending.reference,
    });

    res.status(200).json({
      paymentId,
      status: "undefined",  // Correct for async
      authorizationId: pending.authorizationId ?? null,
      nsu: pending.nsu ?? null,
      tid: pending.tid ?? null,
      acquirer: "MyProvider",
      code: "ASYNC-PENDING",
      message: "Awaiting customer payment",
      delayToAutoSettle: 21600,
      delayToAutoSettleAfterAntifraud: 1800,
      delayToCancel: 604800,  // 7 days for async
      paymentUrl: pending.paymentUrl,
    });
    return;
  }

  // Sync methods can return approved/denied immediately
  // ...
}

错误

async function createPaymentHandler(req: Request, res: Response): Promise<void> {
  const { paymentId, paymentMethod } = req.body;

  // WRONG: Approving a Pix payment synchronously
  // The customer hasn't paid yet — the order will ship without payment
  const result = await acquirer.createPixCharge(req.body);

  res.status(200).json({
    paymentId,
    status: "approved",  // WRONG — Pix is async, should be "undefined"
    authorizationId: result.authorizationId ?? null,
    nsu: null,
    tid: null,
    acquirer: "MyProvider",
    code: null,
    message: null,
    delayToAutoSettle: 21600,
    delayToAutoSettleAfterAntifraud: 1800,
    delayToCancel: 21600,
  });
}

推荐模式

支持幂等性的支付状态存储:

interface PaymentRecord {
  paymentId: string;
  status: "undefined" | "approved" | "denied" | "cancelled" | "settled" | "refunded";
  response: Record<string, unknown>;
  callbackUrl?: string;
  createdAt: Date;
  updatedAt: Date;
}

interface OperationRecord {
  requestId: string;
  paymentId: string;
  operation: "cancel" | "capture" | "refund";
  response: Record<string, unknown>;
  createdAt: Date;
}

使用状态机实现幂等的创建支付:

const store = new PaymentStore();

async function createPaymentHandler(req: Request, res: Response): Promise<void> {
  const { paymentId, paymentMethod, callbackUrl } = req.body;

  // Idempotency check
  const existing = await store.findByPaymentId(paymentId);
  if (existing) {
    res.status(200).json(existing.response);
    return;
  }

  const isAsync = ["BankInvoice", "Pix"].includes(paymentMethod);
  const result = await acquirer.process(req.body);

  const status = isAsync ? "undefined" : result.status;
  const response = {
    paymentId,
    status,
    authorizationId: result.authorizationId ?? null,
    nsu: result.nsu ?? null,
    tid: result.tid ?? null,
    acquirer: "MyProvider",
    code: result.code ?? null,
    message: result.message ?? null,
    delayToAutoSettle: 21600,
    delayToAutoSettleAfterAntifraud: 1800,
    delayToCancel: isAsync ? 604800 : 21600,
    ...(result.paymentUrl ? { paymentUrl: result.paymentUrl } : {}),
  };

  await store.save(paymentId, {
    paymentId, status, response, callbackUrl,
    createdAt: new Date(), updatedAt: new Date(),
  });

  res.status(200).json(response);
}

通过 requestId 防护和状态验证实现幂等的取消:

async function cancelPaymentHandler(req: Request, res: Response): Promise<void> {
  const { paymentId } = req.params;
  const { requestId } = req.body;

  // Operation idempotency check
  const existingOp = await store.findOperation(requestId);
  if (existingOp) {
    res.status(200).json(existingOp.response);
    return;
  }

  // State machine validation
  const payment = await store.findByPaymentId(paymentId);
  if (!payment || !["undefined", "approved"].includes(payment.status)) {
    res.status(200).json({
      paymentId,
      cancellationId: null,
      code: "cancel-failed",
      message: `Cannot cancel payment in ${payment?.status ?? "unknown"} state`,
      requestId,
    });
    return;
  }

  const result = await acquirer.cancel(paymentId);
  const response = {
    paymentId,
    cancellationId: result.cancellationId ?? null,
    code: result.code ?? null,
    message: result.message ?? "Successfully cancelled",
    requestId,
  };

  await store.updateStatus(paymentId, "cancelled");
  await store.saveOperation(requestId, {
    requestId, paymentId, operation: "cancel", response, createdAt: new Date(),
  });

  res.status(200).json(response);
}

常见故障模式

  • 处理重复支付 — 每次收到创建支付请求时都调用收单机构,而不检查 paymentId 是否已存在。网关最多会对状态为 undefined 的支付重试 7 天,因此一笔 $100 的支付可能产生数百笔重复扣款。
  • 同步批准异步支付方式 — 在客户实际付款之前,就立即对 Boleto 或 Pix 返回 status: "approved"。订单发货了,却没有收到款项。
  • 重试期间丢失状态 — 将支付状态存储在内存中(Map、局部变量),而不是持久化数据库中。进程重启后,所有状态都会丢失,下一次重试就会产生重复扣款。
  • 为重复请求生成新的标识符 — 网关使用同一个 paymentId 重试时,返回不同的 tid、nsu 或 authorizationId 值。这会破坏网关对账,并可能导致重复结算。
  • 忽略 Cancel/Capture/Refund 中的 requestId — 处理操作前不检查 requestId,导致网关重试时重复取消或退款。

审查清单

  • 创建支付处理程序是否会在调用收单机构之前检查数据存储中是否已有对应的 paymentId?
  • 对于重复的 paymentId 请求,是否会原样返回已存储的响应?
  • Cancel、Capture 和 Refund 处理程序是否会在处理前检查是否已有对应的 requestId?
  • 是否强制执行支付状态机(例如,不能捕获已取消的支付)?
  • 异步支付方式(Boleto、Pix)是否返回 status: "undefined",而不是 "approved"?
  • 支付状态是否存储在持久化数据库中(而非仅存于内存中)?
  • 异步支付方式的 delayToCancel 值是否已延长(例如,604800 秒 = 7 天)?

相关技能

参考资料

评论

支持 Markdown