Claude Media
Claude Codeで3Dセキュアのrequires_actionを実装する — フロントとwebhookの分担

Claude Codeで3Dセキュアのrequires_actionを実装する — フロントとwebhookの分担

StripeのPaymentIntentがrequires_actionで止まったとき、画面とwebhookが何をするか。状態ごとの分岐をClaude Codeに書かせ、3DSのテストカードで失敗や離脱まで再現する手順です。

3Dセキュア(3DS)が絡む決済で、実装が壊れやすいのはカード番号の入力ではなく、その後です。PaymentIntentがrequires_actionで止まったとき、画面は何を出し、サーバーは何をしないのか。ここを決めずにClaude Codeへ「3DS対応して」と頼むと、成功時の経路しか書かれないことがあります。

この記事は、PaymentIntentの状態ごとに画面とwebhookの役割を分け、Claude Codeに実装させ、Stripeのテストカードで成功・拒否・離脱を再現する手順です。対象はPayment Intents APIとStripe.jsの構成です。Stripeは新規の実装ではCheckout Sessionsを勧めており、Payment Intentsはコード量が増えるため、明示的に必要なときだけ使う位置づけです。

requires_actionは何の状態か

requires_actionは、3DSでの認証など、決済の完了に追加のアクションが必要なときのPaymentIntentのstatusです。失敗でも成功でもなく、顧客の操作を待っている途中の状態にあたります。

3DSが起動する条件は、Stripeが自動で判断します。欧州のSCAや、日本のクレジットカード・セキュリティガイドラインのような規制や業界ガイドラインで求められる場合、イシュアが再試行可能な拒否を返した場合、Stripeが最適化を適用する場合です。Radarのルールや、payment_method_options[card][request_three_d_secure]で手動でも要求できます。anyはフリクションレス(追加入力なし)を優先し、challengeは顧客の操作を伴うチャレンジを優先します。最終的なフローを決めるのはカード発行会社なので、希望どおりになる保証はありません。

注意したいのは、requires_actionを経由しない決済もあることです。免除などで3DSが不要と判断されれば、processingへ進みます。3DSに対応していないカードは、認証なしで続行されます。Stripeが必要なデータをすでに持っていれば、確定の時点で認証が完了し、requires_actionを挟まずprocessingへ直行することもあります。つまり、requires_actionを通る前提でテストを書くと、通らない経路を見落とします。

状態ごとに画面とwebhookの役割を分ける

分岐の設計図を、先に表にしておきます。Claude Codeには、この表をそのまま渡します。

status意味画面webhook
requires_action意味認証待ち画面認証UIを出す。閉じられたら再開できる導線を残すwebhookpayment_intent.requires_actionは記録だけ。注文は確定しない
processing意味結果待ち画面「確認中」を出すwebhook完了イベントを待つ
succeeded意味決済完了画面完了画面を出すwebhookpayment_intent.succeededで注文を確定する
requires_payment_method意味認証失敗または拒否画面エラーを出し、別のカードを求めるwebhookpayment_intent.payment_failedで通知や状態更新
requires_capture意味オーソリのみ完了画面与信確保済みを出すwebhookキャプチャー処理へ

役割分担の核は1点です。注文の確定は、画面ではなくwebhookで行います。顧客は決済の完了後、フルフィルメントの前にページを離れることがあります。そのため、クライアント側でフルフィルメントを始めないよう、Stripeはpayment_intent.succeededを監視する方法を示しています。画面はあくまで「結果の表示」を受け持ちます。

requires_payment_methodの扱いも、見落としやすい点です。認証が失敗すると、PaymentIntentはこの状態に戻ります。同じPaymentIntentを再確定して3DSをやり直すことも、別の決済手段を試すこともできます。拒否の理由はlast_payment_errorで読めます。

Claude Codeへの頼み方

いきなり実装させず、まずplan modeで計画だけ出させます。claude --permission-mode planで起動するか、セッション中にShift+Tabで切り替えます。plan modeでは、承認するまでファイルは編集されません。

claude --permission-mode plan

指示文には、上の状態表と、守らせたい制約を入れます。

PaymentIntentの3DS対応を実装したい。条件:
- フロントは confirmPayment(redirect: 'if_required') を使い、結果は status で分岐する
- 注文の確定は webhook の payment_intent.succeeded だけで行う
- requires_action は成功にも失敗にも倒さない
- 先に計画だけ出して。変更するファイルと、テストの観点を一覧にして

計画を見るときは、requires_actionの行に「注文を確定しない」と書かれているかを確かめます。ここが抜けた計画は、実装でも抜けます。

繰り返し守らせたい規約は、CLAUDE.mdに置きます。

## 決済(Stripe 3Dセキュア)
- 注文の確定は webhook の payment_intent.succeeded だけで行う
- requires_action は「未完了」。失敗扱いにも成功扱いにもしない
- 3DSのテストは次のカードだけを使う
  - 4000000000003220: 認証必須・成功
  - 4000008400001629: 認証後に card_declined
  - 4000008400001280: 3DSの処理エラー
  - 4000000032200000: フリクションレスで成功

CLAUDE.mdは、Claudeが従うことが多いものの、毎回守られる保証はない指示です。「テスト失敗のまま終わらせない」のように必ず効かせたい動作は、フックに置きます。Stripe関連のテストをファイル編集のたびに走らせるフックの書き方は、hooksでテストを自動実行する記事にあります。stripe listenの準備はStripe CLIのwebhook開発が受け持ちます。

フロントの実装例

Stripe.jsのconfirmPaymentは、3DSが必要なときに認証UIを自動で処理します。認証や追加ステップが完了するかタイムアウトするまで、返されるPromiseは解決されません。redirect: 'if_required'を指定すると、リダイレクトが要らない決済手段ではページ遷移せずに結果を受け取れます。

以下は、公式の例に沿って状態分岐を関数に切り出した形の一例です。

export type PaymentView =
  | { kind: "success" }
  | { kind: "pending" }
  | { kind: "resume" }
  | { kind: "retry"; message: string };
 
export function viewFor(pi: {
  status: string;
  last_payment_error?: { message?: string } | null;
}): PaymentView {
  switch (pi.status) {
    case "succeeded":
      return { kind: "success" };
    case "processing":
    case "requires_capture":
      return { kind: "pending" };
    case "requires_action":
      return { kind: "resume" };
    case "requires_payment_method":
      return {
        kind: "retry",
        message: pi.last_payment_error?.message ?? "別のカードをお試しください",
      };
    default:
      return { kind: "pending" };
  }
}
 
const { paymentIntent, error } = await stripe.confirmPayment({
  elements,
  confirmParams: { return_url: "https://example.com/order/complete" },
  redirect: "if_required",
});
if (error) showError(error.message);
else if (paymentIntent) render(viewFor(paymentIntent));

分岐を純粋関数にしておく理由は、テストしやすいからです。stripeやelementsを抜きにして、状態の文字列だけで挙動を固定できます。

リダイレクト型で戻ってきたページでは、URLにpayment_intentとpayment_intent_client_secretが付きます。このclient secretでstripe.retrievePaymentIntentを呼び、同じviewForに渡します。iframeで3DSを出す構成では、戻り先ページからwindow.top.postMessageで親に完了を知らせ、親がretrievePaymentIntentで状態を取り直す手順が示されています。3DSのiframeにはsandbox属性を付けられません。発行会社の実装によってはエラーになり、支払いが通らないためです。CSPを設定しているサイトは、https://js.stripe.comとhttps://hooks.stripe.comのiframeを許可しておく必要があります。

webhookの実装例

サーバー側は、イベントタイプで分けます。PaymentIntent関連のイベントには、payment_intent.requires_action、payment_intent.processing、payment_intent.succeeded、payment_intent.payment_failedがあります。

type Deps = {
  markPending(orderId: string, piId: string): Promise<void>;
  fulfill(orderId: string, piId: string): Promise<void>;
  markFailed(orderId: string, reason: string): Promise<void>;
};
 
export async function handlePaymentIntentEvent(
  event: { type: string; data: { object: any } },
  deps: Deps,
) {
  const pi = event.data.object;
  const orderId = pi.metadata?.order_id;
  if (!orderId) return;
 
  switch (event.type) {
    case "payment_intent.requires_action":
    case "payment_intent.processing":
      await deps.markPending(orderId, pi.id);
      break;
    case "payment_intent.succeeded":
      await deps.fulfill(orderId, pi.id);
      break;
    case "payment_intent.payment_failed":
      await deps.markFailed(orderId, pi.last_payment_error?.message ?? "failed");
      break;
  }
}

order_idをPaymentIntentのmetadataに入れておく設計は、この記事の例の前提です。Stripe側の仕様ではなく、注文とPaymentIntentを結びつける一つのやり方にすぎません。

署名検証、重複配信への備え、イベントの順序のずれは、この関数の外側で受け止めます。実装はwebhookの冪等受信の記事を使います。サブスクリプションの更新で3DSが絡む場合の状態遷移は、サブスク同期の記事で扱っています。

テストカードで成功・拒否・離脱を再現する

テストでは、Stripeが3DS用に挙動を定義したカードだけを使います。3DS向けに設計されていない他のテストカードでは、3DSが起動してもattempt_acknowledgedが返り、追加のステップが迂回されることがあります。見かけ上は動いても、チャレンジのテストにはなりません。

カード番号挙動確かめる分岐
4000000000003220挙動3DS2の認証が必須。完了すれば成功確かめる分岐requires_actionからsucceededへ
4000008400001629挙動認証は必須。認証後にcard_declinedで拒否確かめる分岐認証後の拒否。requires_payment_method
4000008400001280挙動認証が必須だが、3DSの検索リクエストが処理エラー。card_declinedで拒否確かめる分岐3DS基盤側の障害時の見せ方
4000000032200000挙動フリクションレスで認証が通り成功確かめる分岐認証UIが出ない経路
378282246310005挙動3DS非対応。認証なしで続行確かめる分岐requires_actionを通らない経路

テスト用のAPIキーを使うと、認証の場面で模擬ページが表示されます。ここで認証を承認すれば成功が、失敗ボタンを押せば認証失敗がシミュレートされます。つまり、同じカード番号4000000000003220で、承認と失敗の両方を再現できます。ダッシュボードで直接作った決済では3DSのリダイレクトが行われないので、自分のフロントエンドかAPI呼び出しで試します。

離脱の再現は、模擬ページが開いた状態でタブを閉じるだけです。認証が完了しないかぎり、PaymentIntentはrequires_actionから先へ進みません。この状態の注文が増え続けないように、一定時間たった未完了の注文を整理する仕組みが要ります。PaymentIntentのキャンセルはprocessingやsucceededになる前なら行えますが、一度キャンセルすると元に戻せません。待つ時間は業務に合わせて決める値で、Stripeが指定する数値ではありません。

状態の分岐をテストに落とす

画面の分岐とwebhookの分岐は、どちらも関数に切り出してあるので、実カードを使わなくてもテストできます。Claude Codeには、状態の一覧をテスト名にした形で書かせます。

import { describe, it, expect, vi } from "vitest";
import { viewFor } from "./view";
import { handlePaymentIntentEvent } from "./handler";
 
describe("viewFor", () => {
  it("requires_action は再開導線を出す", () => {
    expect(viewFor({ status: "requires_action" }).kind).toBe("resume");
  });
  it("認証失敗は別カードを促す", () => {
    const v = viewFor({
      status: "requires_payment_method",
      last_payment_error: { message: "declined" },
    });
    expect(v).toEqual({ kind: "retry", message: "declined" });
  });
});
 
describe("handlePaymentIntentEvent", () => {
  const mk = () => ({
    markPending: vi.fn(),
    fulfill: vi.fn(),
    markFailed: vi.fn(),
  });
  it("requires_action では注文を確定しない", async () => {
    const deps = mk();
    await handlePaymentIntentEvent(
      { type: "payment_intent.requires_action",
        data: { object: { id: "pi_1", metadata: { order_id: "o1" } } } },
      deps,
    );
    expect(deps.fulfill).not.toHaveBeenCalled();
    expect(deps.markPending).toHaveBeenCalledWith("o1", "pi_1");
  });
});

最後のテストが、この記事の要点を固定します。requires_actionのイベントでfulfillが呼ばれない、という保証です。Claude Codeに実装を直させるとき、このテストが落ちれば、成功扱いへの短絡がその場で見つかります。

結合の確認は、stripe listenでwebhookをローカルに転送し、テストカードで実際に決済して行います。3DSのチャレンジを含む流れは、模擬ページを通すテストカードで行います。

よくあるつまずき

落とし穴

認証UIが出ずに成功してしまう

4242424242424242は3DSに対応していますが、3DSに登録されていないため、Radarが3DSを要求しても顧客は追加認証を求められません。チャレンジの確認には、4000000000003220を使います。

落とし穴

リダイレクト後にclient secretを失う

リダイレクト型では、戻り先のURLにpayment_intent_client_secretが付きます。このクエリを読まずに独自のセッション変数だけに頼ると、状態を取り直せなくなります。戻り先ページでretrievePaymentIntentを呼ぶ処理も、Claude Codeへの指示に含めます。

  • ライアビリティシフトは、3DSに成功した決済に通常適用されるものの、すべての決済や不審請求で保証されるわけではありません。「3DSを通したから不正の責任は負わない」とUIや社内文書に書かないよう、CLAUDE.mdに一行足しておくと安全です。
  • 本番では、カード発行会社の都合で、チャレンジが出ない(フリクションレス)フローや、3DSを使わず続行するケースが混ざります。テスト環境で認証UIが毎回出ることを前提にした画面設計は避けます。

まとめ

requires_actionは失敗でも成功でもなく、顧客を待つ途中の状態です。画面はこの状態を再開できる形で扱い、注文の確定はpayment_intent.succeededのwebhookに限る。この2点を表にして渡し、状態ごとの分岐を純粋関数に切り出させると、Claude Codeが短絡を起こしても、テストが先に落ちます。離脱は模擬ページを閉じるだけで再現できるので、未完了の注文を整理する仕組みまで含めて検証しておくと、本番でのたまり込みを防げます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. docs.stripe.com/payments/3d-secure/authentication-flow
  2. docs.stripe.com/payments/paymentintents/lifecycle
  3. docs.stripe.com/payments/payment-intents/verifying-status
  4. docs.stripe.com/testing
  5. code.claude.com/docs/en/common-workflows
この記事を共有:XはてブLinkedIn