mcpField
P
payment-idempotency

payment-idempotency

github.com/vtex開発471.1k 回インストール2 週間前に更新
リポジトリ

payment-idempotency について

VTEX Payment Provider Protocol のハンドラーで、冪等性と重複処理防止を実装するためのスキルです。状態の永続化、再試行時の同一応答、状態遷移の検証を扱います。

主な機能

  1. 01Create Payment の冪等キーに paymentId
  2. 02requestId による操作の重複排除
  3. 03再試行時に保存済み応答を返却
  4. 04非同期決済は undefined 状態を維持
  5. 05決済状態の永続化と遷移制御

活用シーン

  1. 01決済作成と再試行処理の実装
  2. 02取消・売上確定・返金の重複防止
  3. 03Boleto や Pix などの非同期決済処理
  4. 04決済状態遷移の検証

インストール

コマンドをコピーしてターミナルで実行します。

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

インストール後、Claude Code で/payment-idempotencyを入力して読み込まれたか確認します。

詳細

冪等性と重複防止

このスキルを使用する場面

次の場合にこのスキルを使用してください:

  • 決済、キャンセル、売上確定、返金を処理する PPP エンドポイントハンドラーを実装するとき
  • 同じ識別子を使用した Gateway の呼び出しが繰り返されても、再処理せずに同一の結果を返すようにするとき
  • 無効な状態遷移(例:キャンセル済みの決済を売上確定する)を防止する決済ステートマシンを構築するとき
  • undefined ステータスの決済について、Gateway の 7 日間の再試行期間に対応するとき

次の場合はこのスキルを使用しないでください:

  • PPP エンドポイントのレスポンス形式と HTTP メソッドについては、payment-provider-protocol を使用してください
  • 非同期コールバック URL の通知ロジックについては、payment-async-flow を使用してください
  • PCI コンプライアンスと Secure Proxy については、payment-pci-security を使用してください
  • POS / Sales App の端末決済(Venda Direta Credito、Venda Direta Debito、「Wait for confirmation」のポーリング中に重複する Create Payment)については、このスキルと併せて payment-ppp-pos を使用してください

判断ルール

  • Create Payment の冪等性キーには paymentId を使用してください。同じ paymentId による呼び出しには、常に同じ結果を返す必要があります。
  • Cancel、Capture、Refund 操作の冪等性キーには requestId を使用してください。
  • Gateway から同じ paymentId の 2 回目の 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 などの永続データストアを使用してください。再起動時に失われるインメモリストレージは決して使用しないでください。

厳守事項

制約:Create Payment の冪等性キーには必ず paymentId を使用する

コネクターは、新しい決済を処理する前に、指定された paymentId の既存レコードを必ず確認しなければなりません。レコードが存在する場合は、アクワイアラーを再度呼び出さず、保存済みのレスポンスを返さなければなりません。

重要な理由 VTEX Gateway は、undefined ステータスの Create Payment リクエストを最大 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,
  });
}
制約:重複リクエストには必ず同一のレスポンスを返す

コネクターが、データストアにすでに存在する paymentId を含む Create Payment リクエストを受信した場合、保存済みのレスポンスをそのまま返さなければなりません。新しいレコードの作成、新しい識別子の生成、決済の再処理をしてはなりません。

重要な理由 Gateway は、トランザクションを追跡するためにレスポンスのフィールド(authorizationId、tid、nsu、status)を使用します。再試行で異なる値を返すと、Gateway は元のトランザクションを追跡できなくなり、照合の失敗や二重精算につながる可能性があります。

検出方法 既存の 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、銀行振込リダイレクト)は、Create Payment の応答で status: "undefined" を返す必要があります。アクワイアラーによって実際に決済が確認または拒否されるまで、status: "approved" や status: "denied" を返してはいけません。

重要な理由 非同期決済方法で approved を返すと、顧客が実際に支払う前に、決済が確認されたと Gateway に伝わってしまいます。注文は発送されますが、代金は回収されません。加盟店は商品と売上の両方を失います。正しいフローでは undefined を返し、決済が確認されたときに callbackUrl を使って Gateway に通知します。

検出 Create Payment ハンドラーが非同期決済方法(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;
}

ステートマシンを使用した冪等な Create Payment:

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 がすでに存在するか確認せず、Create Payment リクエストごとにアクワイアラーを呼び出すこと。Gateway は undefined の決済を最大 7 日間再試行するため、1 件の 100 ドルの決済で、重複請求が何百件も発生する可能性があります。
  • 非同期決済方法の同期承認 — 顧客が実際に支払う前に、Boleto または Pix に対して status: "approved" を直ちに返すこと。代金を回収せずに注文を発送してしまいます。
  • 再試行間での状態消失 — 決済状態を永続データベースではなく、メモリ(Map、ローカル変数)に保存すること。プロセスが再起動すると、状態がすべて失われ、次の再試行で重複請求が発生します。
  • 重複リクエストごとに新しい識別子を生成 — Gateway が同じ paymentId で再試行したときに、異なる tid、nsu、または authorizationId の値を返すこと。Gateway の照合が破綻し、二重決済につながる可能性があります。
  • Cancel/Capture/Refund での requestId の無視 — 操作を処理する前に requestId を確認しないこと。Gateway が再試行した際に、キャンセルまたは返金が重複して発生します。

レビューチェックリスト

  • Create Payment ハンドラーは、アクワイアラーを呼び出す前にデータストアを確認し、既存の paymentId がないか調べていますか?
  • 重複する paymentId リクエストに対して、保存済みの応答をそのまま返していますか?
  • Cancel、Capture、Refund のハンドラーは、処理前に既存の requestId を確認していますか?
  • 決済ステートマシンを適用していますか(例:キャンセル済みの決済はキャプチャできない)?
  • 非同期決済方法(Boleto、Pix)は "approved" ではなく、status: "undefined" を返していますか?
  • 決済状態は永続データベースに保存されていますか(メモリ内ではなく)?
  • 非同期決済方法では delayToCancel の値を延長していますか(例:604800 秒 = 7 日)?

関連するスキル

  • payment-provider-protocol — エンドポイントの契約と応答形式
  • payment-async-flow — コールバック URL による通知と 7 日間の再試行期間
  • payment-pci-security — PCI 準拠と Secure Proxy
  • payment-ppp-pos — POS の Create Payment はポーリングされ、1 つの paymentId に対して処理が重複する可能性があります。このスキルの paymentId キーと、そのスキルの etag/ifMatch パターンを使用してください
  • vtex-io-application-performance — VBase の書き込みの正確性(重要な処理経路では await する)、クライアントごとのタイムアウト/再試行設定、IO ベースのコネクターにおけるキャッシュルール

参考資料

コメント