payment-idempotency について
VTEX Payment Provider Protocol のハンドラーで、冪等性と重複処理防止を実装するためのスキルです。状態の永続化、再試行時の同一応答、状態遷移の検証を扱います。
主な機能
- 01Create Payment の冪等キーに paymentId
- 02requestId による操作の重複排除
- 03再試行時に保存済み応答を返却
- 04非同期決済は undefined 状態を維持
- 05決済状態の永続化と遷移制御
活用シーン
- 01決済作成と再試行処理の実装
- 02取消・売上確定・返金の重複防止
- 03Boleto や Pix などの非同期決済処理
- 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 Proxypayment-ppp-pos— POS の Create Payment はポーリングされ、1 つのpaymentIdに対して処理が重複する可能性があります。このスキルのpaymentIdキーと、そのスキルの etag/ifMatchパターンを使用してくださいvtex-io-application-performance— VBase の書き込みの正確性(重要な処理経路では await する)、クライアントごとのタイムアウト/再試行設定、IO ベースのコネクターにおけるキャッシュルール
参考資料
- Implementing a Payment Provider — キャンセル、キャプチャ、返金操作の冪等性要件を説明する公式ガイド
- Developing a Payment Connector for VTEX — paymentId と VBase を使用した冪等性の実装手順を紹介するヘルプセンターのガイド
- Purchase Flows — リトライ動作を含む、承認、キャプチャ、キャンセルのフローに関する詳細なドキュメント
- Payment Provider Protocol (Help Center) — コールバック URL のリトライの仕組みと7日間のリトライ期間を含む、プロトコルの概要
- Payment Provider Protocol API Reference — requestId フィールドと paymentId フィールドの定義を含む、API の完全な仕様