mcpField
P
payment-async-flow

payment-async-flow

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

payment-async-flow について

payment-async-flowは、VTEXコネクターで非同期決済を実装する開発者向けのスキルです。コールバックの処理や安全な再試行を扱い、重複請求を防ぎながら決済状態の変化を反映します。

主な機能

  1. 01非同期決済を判別しundefined状態を返す
  2. 02リクエストのcallbackUrlを変更せず使用
  3. 03ブラウザーリダイレクト用の公開ルートを追加
  4. 04再リクエスト時の重複請求を防止
  5. 05決済の有効期間に合わせdelayToCancelを設定

活用シーン

  1. 01PixやBoleto Bancário決済の実装
  2. 02決済状態コールバックの受信と再試行
  3. 03PayPalや3DSからのブラウザー復帰処理
  4. 04保留中決済へのGateway再呼び出しに対応

インストール

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

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

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

詳細

非同期決済フローとコールバック

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

次の場合にこのスキルを使用します。

  • Boleto Bancário、Pix、銀行振込、またはリダイレクトベースのフローに対応する決済コネクターを実装する場合
  • アクワイアラーが最終ステータスを同期的に返さない決済方法を扱う場合
  • callbackUrl による通知または再試行フローを処理する場合
  • undefined ステータスの決済に対する Gateway の7日間の自動再試行サイクルを管理する場合
  • ユーザーが VTEX のチェックアウトを離れ、外部サイトで決済を完了した後に戻る、リダイレクトベースの認証フロー(例:PayPal、3DS リダイレクト)を実装する場合

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

  • PPP エンドポイントのコントラクトとレスポンス形式については、payment-provider-protocol を使用してください。
  • paymentId/requestId の冪等性とステートマシンのロジックについては、payment-idempotency を使用してください。
  • PCI コンプライアンスと Secure Proxy によるカード処理については、payment-pci-security を使用してください。
  • POS/VTEX Sales App の端末決済(Venda Direta Credito、Venda Direta Debito、Payment Apps)については、payment-ppp-pos を使用してください。

判断ルール

  • アクワイアラーが最終ステータスを同期的に返せない場合、その決済方法は非同期です。status: "undefined" を返してください。
  • 一般的な非同期決済方法:Boleto Bancário(BankInvoice)、Pix、銀行振込、リダイレクトベースの認証。
  • 一般的な同期決済方法:クレジットカード、即時オーソリに対応するデビットカード。
  • VTEX IO を使用しない場合:callbackUrl は通知エンドポイントです。X-VTEX-API-AppKey/X-VTEX-API-AppToken ヘッダーを付けて、更新後のステータスを POST してください。
  • VTEX IO を使用する場合:callbackUrl は再試行エンドポイントです。ペイロードなしで POST し、Gateway に POST /payments を再呼び出しさせます。callbackUrl の形式は通常、https://{account}.vtexpayments.com.br/payment-provider/transactions/{txId}/payments/{paymentId}/retry です。この URL に本文 { paymentId } を付けて POST すると、Gateway は authorize() メソッドを再呼び出しします。
  • callbackUrl 内の X-VTEX-signature クエリパラメーターは必ず保持してください。削除または変更しないでください。
  • inboundRequestsUrl はサーバー間通信専用(POST)です。ブラウザーの GET リダイレクトには対応していません。決済フローでユーザーのブラウザーをリダイレクトして戻す必要がある場合(例:PayPal、3DS)は、カスタムの公開ルートを作成してください。以下の「リダイレクトベースのフロー」の制約を参照してください。
  • 非同期決済方法では、delayToCancel に7日間の内部 Gateway 再試行期間ではなく、決済方法の実際の有効期限を反映させる必要があります。
    • Pix:QR コードの有効期限に合わせ、900~3600秒(15~60分)にしてください。
    • BankInvoice(Boleto):プロバイダーで設定された請求書の支払期日/支払期限に合わせてください。
    • その他の非同期決済方法:プロバイダーが文書で定める有効期限の SLA に合わせてください。

厳守すべき制約

制約:非同期決済方法では必ず undefined を返す

オーソリが同期的に完了しない決済方法(Boleto、Pix、銀行振込、リダイレクトベースの認証)では、Create Payment のレスポンスに必ず status: "undefined" を使用してください。決済がアクワイアラーによって実際に確定または拒否されるまで、コネクターは "approved" または "denied" を返してはいけません。

これが重要な理由 未確認の決済に "approved" を返すと、Gateway は入金済みと判断します。その結果、注文はすぐにフルフィルメントに回されます。顧客が実際には決済しなかった場合(例:Pix の QR コードをスキャンしなかった場合)、加盟店は未入金のまま商品を発送してしまいます。早まって "denied" を返すと、まだ完了する可能性のある決済がキャンセルされます。

検出方法 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 asyncMethods = ["BankInvoice", "Pix"];
  const isAsync = asyncMethods.includes(paymentMethod);

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

    // Store payment and callbackUrl for later notification
    await store.save(paymentId, {
      paymentId,
      status: "undefined",
      callbackUrl,
      acquirerReference: pending.reference,
    });

    res.status(200).json({
      paymentId,
      status: "undefined",  // Correct: payment is pending
      authorizationId: pending.authorizationId ?? null,
      nsu: pending.nsu ?? null,
      tid: pending.tid ?? null,
      acquirer: "MyProvider",
      code: "PENDING",
      message: "Awaiting customer action",
      delayToAutoSettle: 21600,
      delayToAutoSettleAfterAntifraud: 1800,
      delayToCancel: computeDelayToCancel(paymentMethod, pending),
      paymentUrl: pending.qrCodeUrl ?? pending.boletoUrl ?? undefined,
    });

    return;
  }

  // Synchronous methods (credit card) can return final status
  const result = await acquirer.authorizeSyncPayment(req.body);

  res.status(200).json({
    paymentId,
    status: result.status,  // "approved" or "denied" is OK for sync
    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,
  });
}

const PIX_MIN_DELAY = 900;   // 15 minutes
const PIX_MAX_DELAY = 3600;  // 60 minutes

function computeDelayToCancel(paymentMethod: string, pending: any): number {
  if (paymentMethod === "Pix") {
    // Use provider QR TTL but clamp to 15–60 minutes
    const providerTtlSeconds = pending.pixTtlSeconds ?? 1800; // default 30 min
    return Math.min(Math.max(providerTtlSeconds, PIX_MIN_DELAY), PIX_MAX_DELAY);
  }

  if (paymentMethod === "BankInvoice") {
    // Example: seconds until boleto due date
    const now = Date.now();
    const dueDate = new Date(pending.dueDate).getTime();
    const diffSeconds = Math.max(Math.floor((dueDate - now) / 1000), 0);
    return diffSeconds;
  }

  // Other async methods: follow provider SLA if provided
  if (pending.expirySeconds) {
    return pending.expirySeconds;
  }

  // Conservative fallback: 24h
  return 86400;
}

誤った例

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

  // WRONG: Creating a Pix charge and immediately returning "approved"
  // The customer hasn't scanned the QR code yet — no money collected
  const pixCharge = await acquirer.createPixCharge(req.body);

  res.status(200).json({
    paymentId,
    status: "approved",  // WRONG — Pix hasn't been paid yet!
    authorizationId: pixCharge.id,
    nsu: null,
    tid: null,
    acquirer: "MyProvider",
    code: null,
    message: null,
    delayToAutoSettle: 21600,
    delayToAutoSettleAfterAntifraud: 1800,
    delayToCancel: 21600,
  });
}
制約:リクエストの callbackUrl を必ず使用すること — ハードコードしない

コネクターは、Create Payment リクエストの本文で提供された正確な callbackUrl を、すべてのクエリパラメーター(X-VTEX-signature など)も含めて使用する必要があります。コネクターは、コールバック URL をハードコードしたり、手動で構築したりしてはいけません。

重要な理由 callbackUrl には、Gateway がコールバックを検証するために使用する、トランザクション固有の認証トークン(X-VTEX-signature)が含まれています。ハードコードまたは変更された URL は Gateway に拒否され、決済が永久に undefined 状態のままになります。また、URL の形式は環境(本番環境とサンドボックス環境)によって変わる場合があります。

検出方法 コネクターがコールバック URL の文字列をハードコードしている、URL を手動で構築している、または callbackUrl からクエリパラメーターを削除している場合は、開発者に警告してください。callbackUrl は受け取ったとおりに保存し、そのまま使用する必要があります。

正しい例

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

  // Store the exact callbackUrl from the request
  await store.save(paymentId, {
    paymentId,
    status: "undefined",
    callbackUrl,  // Stored exactly as received, including query params
  });

  // Return async "undefined" response (see previous constraint)
  res.status(200).json({
    paymentId,
    status: "undefined",
    authorizationId: null,
    nsu: null,
    tid: null,
    acquirer: "MyProvider",
    code: "PENDING",
    message: "Awaiting customer action",
    delayToAutoSettle: 21600,
    delayToAutoSettleAfterAntifraud: 1800,
    delayToCancel: 86400,
  });
}

// When the acquirer webhook arrives, use the stored callbackUrl
async function handleAcquirerWebhook(req: Request, res: Response): Promise<void> {
  const { paymentReference, status } = req.body;

  const payment = await store.findByAcquirerRef(paymentReference);
  if (!payment) {
    res.status(404).send();
    return;
  }

  const pppStatus = status === "paid" ? "approved" : "denied";

  // Update local state first
  await store.updateStatus(payment.paymentId, pppStatus);

  // Use the EXACT stored callbackUrl — do not modify it
  await fetch(payment.callbackUrl, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-VTEX-API-AppKey": process.env.VTEX_APP_KEY!,
      "X-VTEX-API-AppToken": process.env.VTEX_APP_TOKEN!,
    },
    body: JSON.stringify({
      paymentId: payment.paymentId,
      status: pppStatus,
    }),
  });

  res.status(200).send();
}

誤った例

// WRONG: Hardcoding callback URL — ignores X-VTEX-signature and environment
async function handleAcquirerWebhook(req: Request, res: Response): Promise<void> {
  const { paymentReference, status } = req.body;
  const payment = await store.findByAcquirerRef(paymentReference);

  // WRONG — hardcoded URL, missing X-VTEX-signature authentication
  await fetch("https://mystore.vtexpayments.com.br/api/callback", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      paymentId: payment.paymentId,
      status: status === "paid" ? "approved" : "denied",
    }),
  });

  res.status(200).send();
}
制約:inboundRequestsUrl はサーバー間通信専用 — リダイレクトフローにはカスタムの公開ルートが必要

PPP が提供する inboundRequestsUrl は、サーバーからの POST リクエストのみを受け付けます。ブラウザーの GET リダイレクト(たとえば、PayPal、3DS、または外部チェックアウトからユーザーが戻る場合)は、この URL から 400 Bad Request を返されます。

リダイレクトベースの決済フローでは、コネクターはブラウザーのリダイレクトを受け取るために、VTEX IO アプリにカスタムの公開ルートを作成し、保存済みの callbackUrl を使用して Gateway の再試行をトリガーする必要があります。

重要な理由

多くの決済プロバイダー(PayPal、MercadoPago、銀行リダイレクト、3DS)では、決済完了後にユーザーのブラウザーを加盟店にリダイレクトする必要があります。コネクターが inboundRequestsUrl をリダイレクト先として使用すると、ユーザーには 400 エラーが表示され、決済が確認されません。これは、標準の PPP または PPF のドキュメントには記載されていない、よくある厄介な障害です。

検出方法

コネクターが、ユーザーのブラウザーをリダイレクトする外部決済プロバイダーの return_url または redirect_url として inboundRequestsUrl を使用している場合は、処理を停止してください。代わりにカスタムの公開ルートを作成してください。

正しい例 — リダイレクトコールバック用のカスタムルート

ステップ 1:service.json(プロジェクトのルート)にルートを追加します。

{
  "memory": 256,
  "ttl": 10,
  "timeout": 10,
  "minReplicas": 2,
  "maxReplicas": 10,
  "routes": {
    "providerCallback": {
      "path": "/_v/my-connector/callback",
      "public": true
    }
  }
}

ステップ 2:node/index.ts でハンドラーを登録します。

import { PaymentProviderService } from '@vtex/payment-provider'
import MyConnector from './connector'
import { Clients } from './clients'
import { callbackHandler } from './handlers/callback'

export default new PaymentProviderService({
  connector: MyConnector,
  clients: {
    implementation: Clients,
    options: { default: { retries: 2, timeout: 15000 } },
  },
  routes: {
    providerCallback: callbackHandler,
  },
})

ステップ 3:ブラウザーの GET リクエストを受け取り、状態を更新し、Gateway の再試行をトリガーするハンドラーを実装します。

// node/handlers/callback.ts
export async function callbackHandler(ctx: Context) {
  const { paymentId, cancel } = ctx.query
  const { vbase } = ctx.clients

  // Load the stored payment record
  const payment = await vbase.getJSON<PaymentRecord>('payments', paymentId as string)

  if (!payment) {
    ctx.status = 404
    ctx.body = 'Payment not found'
    return
  }

  // Update status based on user action
  if (cancel === 'true') {
    payment.status = 'user-cancelled'
  } else {
    payment.status = 'user-returned'
  }
  await vbase.saveJSON('payments', paymentId as string, payment)

  // Trigger Gateway retry — POST to the stored callbackUrl
  // This makes the Gateway re-call authorize(), which will read the updated status
  await fetch(payment.callbackUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ paymentId }),
  })

  // Redirect user back to VTEX checkout
  const returnUrl = payment.returnUrl || `https://${ctx.vtex.account}.myvtex.com/checkout`
  ctx.redirect(returnUrl)
}

ステップ 4:authorize() で、外部プロバイダーのリターン URL としてカスタムルートを使用します。

// Inside the PaymentProvider subclass
async authorize(authorization: AuthorizationRequest) {
  const vtexCtx = (this.context as any).vtex as { account: string; workspace: string }
  const account = vtexCtx?.account || ''
  const workspace = vtexCtx?.workspace || 'master'
  const workspacePrefix = workspace !== 'master' ? `${workspace}--` : ''
  const callbackBase = `https://${workspacePrefix}${account}.myvtex.com/_v/my-connector/callback`

  // Use YOUR custom route as the return URL — not inboundRequestsUrl
  const returnUrl = `${callbackBase}?paymentId=${authorization.paymentId}`
  const cancelUrl = `${callbackBase}?paymentId=${authorization.paymentId}&cancel=true`

  // Create the payment at the external provider with these URLs
  const externalOrder = await this.clients.psp.createOrder({
    amount: authorization.value,
    return_url: returnUrl,
    cancel_url: cancelUrl,
  })

  // Store the callbackUrl and return undefined status with paymentUrl
  await this.vbase.saveJSON('payments', authorization.paymentId, {
    status: 'pending',
    callbackUrl: authorization.callbackUrl,
    returnUrl: authorization.returnUrl,
    externalOrderId: externalOrder.id,
  })

  return {
    paymentId: authorization.paymentId,
    status: 'undefined',
    paymentUrl: externalOrder.checkoutUrl,  // User is redirected here
    // ... other required fields
  }
}

誤った例

// WRONG — using inboundRequestsUrl as browser redirect target
async authorize(authorization: AuthorizationRequest) {
  const externalOrder = await this.clients.psp.createOrder({
    amount: authorization.value,
    // inboundRequestsUrl only accepts POST from servers — browser GET returns 400
    return_url: authorization.inboundRequestsUrl,
  })

  return {
    paymentId: authorization.paymentId,
    status: 'undefined',
    paymentUrl: externalOrder.checkoutUrl,
  }
}
制約:Create Payment の呼び出しが繰り返されても対応できること(冪等であること。ただしステータスは変化する場合があります)

コネクターは、再試行期間中に Gateway が同じ paymentId で Create Payment(POST /payments)を複数回呼び出す場合に対応しなければなりません。各呼び出しでアクワイアラーに新たな課金を作成してはならず、その paymentId についてローカルに永続化された状態に基づくレスポンスを返し、コールバック後に変化している可能性のある現在のステータス("undefined"、"approved"、または "denied")を反映しなければなりません。

冪等性が対象とするのは、アクワイアラーに対する副作用です。最初の呼び出しで課金を作成し、再試行ではアクワイアラーを再度呼び出してはなりません。非同期方式では、ステータスが "undefined" から "approved" または "denied" に変化することはあり得ますが、それは Webhook によってローカルストアが更新された場合に限られます。

重要な理由 Gateway は、undefined ステータスの支払いに対する POST /payments を最大 7 日間、自動的に再試行します。コネクターが呼び出しごとに新たな支払いとして処理すると、アクワイアラーで二重課金が発生します。更新後のステータスを確認せず、常に元の "undefined" レスポンスを返すと、Gateway は支払いが承認されたことを把握できず、最終的に支払いをキャンセルします。

検出 Create Payment ハンドラーがアクワイアラーを呼び出す前に既存の paymentId を確認しない場合、またはストレージ内の現在のステータスを確認せずに常に元のレスポンスを返す場合、エージェントは処理を停止し、保存された状態のみに基づいてステータスが変化する適切な冪等性を実装するよう開発者を案内しなければなりません。

正しい例

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

  // Check for existing payment — may have been updated via callback
  const existing = await store.findByPaymentId(paymentId);
  if (existing) {
    // Do NOT call the acquirer again.
    // Return a response derived from the current stored state.
    res.status(200).json({
      ...existing.response,
      status: existing.status,  // Reflect the latest state: "undefined" | "approved" | "denied"
    });
    return;
  }

  // First time — call the acquirer once
  const asyncMethods = ["BankInvoice", "Pix"];
  const isAsync = asyncMethods.includes(paymentMethod);

  const acquirerResult = await acquirer.authorize(req.body);

  const initialStatus = isAsync ? "undefined" : acquirerResult.status;

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

  await store.save(paymentId, {
    paymentId,
    status: initialStatus,
    response,
    callbackUrl,
    acquirerReference: acquirerResult.reference,
  });

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

誤った例

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

  // WRONG: No idempotency — every retry hits the acquirer again
  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,
  });
}
制約:delayToCancel を支払いの有効期間に合わせること(常に 7 日にしない)

非同期方式では、Create Payment レスポンスの delayToCancel フィールドは、購入者にとってその支払いが有効とみなされる期間を表さなければなりません。最終ステータスに達していない支払いを Gateway が自動キャンセルできるタイミングを定義します。

ルール:

  • Pix:delayToCancel は 900~3600 秒(15~60 分)の範囲でなければなりません。この値は、プロバイダーで設定した QR コードの有効期間と一致しなければなりません。
  • BankInvoice(Boleto):delayToCancel は、設定された支払期日/支払い期限から計算しなければなりません(例:請求書の支払期日までの秒数)。Gateway 内部の再試行期間に「合わせる」ために、7 日をハードコードしてはなりません。
  • その他の非同期方式:delayToCancel は、プロバイダーが定める有効期限の SLA(該当する場合は時間または日数)に従わなければなりません。プロバイダー側での実際の支払い有効期間を超えてはなりません。

7 日間という期間は、ステータスが undefined の場合に再試行するための、Gateway 内部の安全上の上限です。すべての非同期方式で delayToCancel = 604800 にすべきという意味ではありません。

重要な理由 Pix で数日間の delayToCancel を設定すると、QR コードの有効期限が切れた後も注文が「承認中」のままになり、ユーザー体験の低下や運用上のノイズにつながります。Boleto で実際の支払期日より前にキャンセルすると販売機会を失い、遅すぎると照合リスクや「ゾンビ」注文が発生します。delayToCancel が一致していないと、支払いが有効であるとするプロバイダー側の認識と、VTEX が支払いを自動キャンセルするタイミングとの整合性が崩れます。

検出 コネクターがすべての非同期方式で常に delayToCancel = 604800 を使用する場合、または Pix や Boleto の有効期間を超える delayToCancel を設定する場合、エージェントは delayToCancel の設定が誤っていると警告しなければなりません。

正しい例

(上記の「MUST return undefined」の例にある computeDelayToCancel 関数を参照してください。)

誤った例

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

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

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

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

    res.status(200).json({
      paymentId,
      status: "undefined",
      authorizationId: pending.authorizationId ?? null,
      nsu: pending.nsu ?? null,
      tid: pending.tid ?? null,
      acquirer: "MyProvider",
      code: "PENDING",
      message: "Awaiting customer action",
      delayToAutoSettle: 21600,
      delayToAutoSettleAfterAntifraud: 1800,
      // WRONG: hardcoded 7 days for every async method
      delayToCancel: 604800,
      paymentUrl: pending.qrCodeUrl ?? pending.boletoUrl ?? undefined,
    });

    return;
  }

  // ...
}

推奨パターン

非 VTEX IO のデータフロー(通知コールバック):

1. Gateway → POST /payments → Connector (returns status: "undefined")
2. Acquirer webhook → Connector (payment confirmed)
3. Connector → POST callbackUrl (with X-VTEX-API-AppKey/AppToken headers)
4. Gateway updates payment status to approved/denied

VTEX IO のデータフロー(再試行コールバック):

1. Gateway → POST /payments → Connector (returns status: "undefined")
2. Acquirer webhook → Connector (payment confirmed)
3. Connector → POST callbackUrl (retry, no payload)
4. Gateway → POST /payments → Connector (returns status: "approved"/"denied")

VTEX IO におけるリダイレクトベースのフローのデータフロー:

1. Gateway → POST /payments → Connector (returns status: "undefined" + paymentUrl)
2. Checkout redirects user to paymentUrl (external provider)
3. User completes payment on external site
4. External provider redirects user's browser to custom route (/_v/connector/callback?paymentId=...)
5. Custom route handler updates VBase status, POSTs to callbackUrl
6. Gateway → POST /payments → Connector (reads updated status, returns "approved"/"denied")
7. Custom route redirects user back to VTEX checkout

リダイレクトフローにおける VBase でのステータスの変化:

pending → (user completes on external site) → user-returned → (authorize re-called) → approved
pending → (user cancels on external site)   → user-cancelled → (authorize re-called) → denied

支払い方法の分類:

const ASYNC_PAYMENT_METHODS = new Set([
  "BankInvoice",   // Boleto Bancário
  "Pix",           // Pix instant payments
]);

function isAsyncPaymentMethod(paymentMethod: string): boolean {
  return ASYNC_PAYMENT_METHODS.has(paymentMethod);
}

コールバック通知を伴うアクワイアラーの Webhook ハンドラー(非 VTEX IO):

async function handleAcquirerWebhook(req: Request, res: Response): Promise<void> {
  const webhookData = req.body;
  const acquirerRef = webhookData.transactionId;

  const payment = await store.findByAcquirerRef(acquirerRef);
  if (!payment || !payment.callbackUrl) {
    res.status(404).json({ error: "Payment not found" });
    return;
  }

  const pppStatus = webhookData.status === "paid" ? "approved" : "denied";

  // Update local state FIRST
  await store.updateStatus(payment.paymentId, pppStatus);

  // Notify the Gateway via callbackUrl with retry logic
  await notifyGateway(payment.callbackUrl, {
    paymentId: payment.paymentId,
    status: pppStatus,
  });

  res.status(200).json({ received: true });
}

指数バックオフを使用したコールバックの再試行:

async function notifyGateway(callbackUrl: string, payload: object): Promise<void> {
  const maxRetries = 3;
  const baseDelay = 1000; // 1 second

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(callbackUrl, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-VTEX-API-AppKey": process.env.VTEX_APP_KEY!,
          "X-VTEX-API-AppToken": process.env.VTEX_APP_TOKEN!,
        },
        body: JSON.stringify(payload),
      });

      if (response.ok) return;
      console.error(`Callback attempt ${attempt + 1} failed: ${response.status}`);
    } catch (error) {
      console.error(`Callback attempt ${attempt + 1} error:`, error);
    }

    if (attempt < maxRetries) {
      await new Promise((r) => setTimeout(r, baseDelay * Math.pow(2, attempt)));
    }
  }

  // All retries failed — Gateway will still retry via /payments as safety net
  console.error("All callback retries exhausted. Relying on Gateway retry.");
}

よくある失敗パターン

  • 非同期決済を同期的に承認する — QR コードや振込票を正常に生成できたという理由で、Pix または Boleto に対して status: "approved" を返します。QR コードの生成は、入金の確認とは異なります。代金を受け取らないまま注文が発送されます。
  • ブラウザーのリダイレクト先に inboundRequestsUrl を使用する — inboundRequestsUrl が受け付けるのは、サーバー間の POST のみです。ブラウザーの GET リダイレクト(PayPal、3DS などからのもの)は 400 Bad Request を返します。代わりに、カスタムの公開ルート(/_v/{connector}/callback)を作成してください。
  • callbackUrl を無視する — Create Payment リクエストの callbackUrl を保存せず、Gateway の自動再試行だけに頼ります。再試行の間隔は時間の経過とともに長くなるため、決済から注文承認までに長い遅延が生じます。最悪の場合、顧客が支払いを済ませていても、7 日間の期限が切れて決済がキャンセルされます。
  • コールバック URL をハードコードする — リクエストに含まれる URL を使用せずにコールバック URL を手動で組み立てると、X-VTEX-signature パラメーターが削除されます。Gateway はコールバックを拒否し、決済は undefined のままになります。
  • コールバック失敗時の再試行処理がない — callbackUrl を一度だけ呼び出し、失敗時に通知を黙って破棄します。Gateway は決済が承認されたことを把握できず、次の再試行まで、または自動キャンセルされるまで、決済は undefined のままになります。
  • 再試行時に古いステータスを返す — コールバックによってステータスが更新されたか確認せず、常に元の undefined の応答を返します。Gateway は approved ステータスを確認できず、最終的に決済をキャンセルします。
  • delayToCancel の設定が適切でない — Pix に 7 日を設定すると、QR コードの期限が切れても注文が「Authorizing」のままになります。Boleto に任意の値を設定すると、請求書の支払期日と一致しません。
  • カスタムコールバック用の service.json またはルート登録がない — カスタムルートがコード内にあっても、service.json に宣言されていない、または PaymentProviderService の routes で渡されていない場合、VTEX IO はルートを公開しません。
  • コールバック URL の構築時に this.context へ誤った方法でアクセスする — PPF で this.context.vtex.account ではなく this.context.account を使用します。正しい IOContext のアクセスパターンについては、payment-provider-framework スキルを参照してください。

レビューチェックリスト

  • 非同期決済方法(Boleto、Pix)は、Create Payment で status: "undefined" を返しますか?
  • callbackUrl は、リクエストで受け取ったとおりに(すべてのクエリーパラメーターを含めて)保存されていますか?
  • Webhook ハンドラーは、callbackUrl を呼び出す前にローカルの状態を更新しますか?
  • callbackUrl を呼び出すとき、X-VTEX-signature は保持されていますか?
  • 通知コールバック(VTEX IO 以外)に X-VTEX-API-AppKey と X-VTEX-API-AppToken のヘッダーが含まれていますか?
  • コールバック呼び出しの失敗時に、指数バックオフを使った再試行処理がありますか?
  • Create Payment ハンドラーは既存の paymentId を確認し、再試行時にアクワイアラーへの再呼び出しを避け、現在保存されている状態に基づく応答を返しますか(コールバック後にステータスが "undefined" から "approved"/"denied" に変わる場合があります)?
  • Pix の場合、delayToCancel は 900〜3600 秒(15〜60 分)で、QR コードの有効期間と一致していますか?
  • BankInvoice(Boleto)の場合、delayToCancel はプロバイダーで設定された実際の支払期限/期日を反映していますか?
  • ほかの非同期決済方法の場合、delayToCancel はプロバイダーのドキュメントに記載された有効期限の SLA と一致し、実際の決済有効期間を超えていませんか?
  • リダイレクトを伴うフローでは、inboundRequestsUrl ではなくカスタムの公開ルートを使用していますか?
  • カスタムルートは service.json で宣言され、PaymentProviderService のルートに登録されていますか?
  • リダイレクトハンドラーは VBase の状態を更新し、callbackUrl に POST してから、ユーザーをチェックアウトへリダイレクトしますか?

関連スキル

リファレンス

コメント