Stripeを使って、保存済みのクレジットカードに対してサーバー側で決済処理を行う方法（3Dセキュア対応方付き


この記事では、保存したクレジットカード情報などを利用したオンライン決済フローにおける3Dセキュア認証の対応方法を、Stripeを例に紹介します。2025年3月に義務化される3Dセキュア認証への対応方法を知るため、そしてよりユーザー・開発者のどちらにもシンプルでセキュアな決済フローを実現するための方法を、サンプルコードを使って解説します。

## Stripeの`Setup Intents API`でクレジットカード情報を保存する

クレジットカード情報を安全に保存・利用するためには、安全な決済フォームの提供と保存場所の確保が必要です。Stripeを利用する場合、[Setup Intents API]()を利用した決済フォームを提供することで、これらを実現できます。

![スクリーンショット 2024-10-23 15.03.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/8585ee6e-5fbe-38d2-88e1-8d470d535c27.png)


[Hono](https://hono.dev)を利用している場合は、次のようなコードでカード情報を保存するフォームが用意できます。

```js
import { Hono } from 'hono'
import { Stripe } from 'stripe'
import { env } from 'hono/adapter'

const app = new Hono()


app.get('/save-card', async c => {
  const { STRIPE_PUB_API_KEY, STRIPE_SECRET_API_KEY } = env(c)
  const stripe = new Stripe(STRIPE_SECRET_API_KEY, {
    apiVersion: '2024-04-10'
  })
  const { id: customerId } = await stripe.customers.create()
  const setupIntent = await stripe.setupIntents.create({
    customer: customerId,
  })
  return c.html(`
  <!DOCTYPE html>
  <html>
  <head>
    <title>Stripe Card Information Save Demo</title>
    <script src="https://js.stripe.com/v3/"></script>
  </head>
  <body>
    <h1>Stripe Card Information Save Demo</h1>
    <form id="payment-form">
      <div id="payment-element"></div>
      <button id="submit-button">Save Card</button>
      <div id="error-message"></div>
    </form>
  
    <script>
      const stripe = Stripe('${STRIPE_PUB_API_KEY}');

      // Stripe Elementsを初期化
      const elements = stripe.elements({
        clientSecret: "${setupIntent.client_secret}"
      });
      const paymentElement = elements.create('payment');
      paymentElement.mount('#payment-element');

      // フォームの送信イベントを処理
      const form = document.getElementById('payment-form');
      form.addEventListener('submit', async (event) => {
        event.preventDefault();
        // Use the clientSecret and Elements instance to confirm the setup
        const result = await stripe.confirmSetup({
          elements,
          confirmParams: {
            return_url: 'http://localhost:8787/save-card',
          },
        });
      
        console.log(result)
        if (result.error) {
          const errorMessage = document.getElementById('error-message');
          errorMessage.textContent = result.error.code + ':' + result.error.message;
        }
      });
    </script>
  </body>
  </html>`) 
})

export default app
```

より簡単な方法として、Stripe Checkoutの`setup`モードを使うこともできます。アプリケーションに決済フォームを埋め込むこともできますので、より決済周りの実装コードを減らせます。

![スクリーンショット 2024-10-23 15.14.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/ba39c321-d3f4-ddcf-dc78-d8ffd55171fd.png)

```js
import { Hono } from 'hono'
import { Stripe } from 'stripe'
import { env } from 'hono/adapter'

const app = new Hono()


app.get('/save-card', async c => {
  const { STRIPE_PUB_API_KEY, STRIPE_SECRET_API_KEY } = env(c)
  const stripe = new Stripe(STRIPE_SECRET_API_KEY, {
    apiVersion: '2024-04-10'
  })
  const { id: customerId } = await stripe.customers.create()
  const currentUrl = new URL(c.req.url)
  const baseUrl = `${currentUrl.protocol}//${currentUrl.host}`
  const checkoutSession = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'setup',
    ui_mode: 'embedded',
    currency: 'jpy',
    return_url: `${baseUrl}/save-card`
  })
  
  return c.html(`
  <!DOCTYPE html>
  <html>
  <head>
    <title>Stripe Card Information Save Demo</title>
    <script src="https://js.stripe.com/v3/"></script>
  </head>
  <body>
    <h1>Stripe Card Information Save Demo</h1>
    <form id="payment-form">
      <div id="payment-element"></div>
      <button id="submit-button">Save Card</button>
      <div id="error-message"></div>
    </form>
  
    <script>
      const stripe = Stripe('${STRIPE_PUB_API_KEY}');

      // Stripe Elementsを初期化
      stripe.initEmbeddedCheckout({
        fetchClientSecret: async () => "${checkoutSession.client_secret}"
      }).then(elements => {
        elements.mount('#payment-element');
      })
    </script>
  </body>
  </html>`) 
})

export default app
```

## 保存したカード情報を利用して決済処理を行う方法

ElementsやCheckoutを利用して保存したクレジットカード情報は、[Payment Methods](https://docs.stripe.com/api/payment_methods)リソースとして扱えます。保存したカードの一覧を取得するには、サーバー側で次のようなコードを実行しましょう。取得した決済手段の一覧をユーザーに提示することで、顧客が支払いに利用するカード情報を指定するUIを作ることができます。

```js
const paymentMethods = await stripe.paymentMethods.list({
   customer: customerId,
   type: 'card'
 })
```

利用したい決済手段の有効性チェックや、誤って別の顧客が登録したクレジットカードを利用してしまわないため、事前に[Payment Method API](https://www.google.com/url?q=https://docs.stripe.com/api/setup_intents/object%23setup_intent_object-payment_method&sa=D&source=docs&ust=1732675060253966&usg=AOvVaw1ZEfIDLn8z7cUyN9Pv2Bog)でリソースの取得を行うことをおすすめします。

```js
async function retrieveCustomerPaymentMethod(customerId, paymentMethodId) {
  try {
    const pm = await stripe.customers.retrievePaymentMethod(customerId, paymentMethodId)
    return pm
  } catch (e) {
    if (e.statusCode === 404) {
      throw new Error("No such payment method")
    }
    throw new Error("Internal server error")
  }
}
```

顧客IDと決済手段のIDが取得できれば、あとは[Payment Intent API](https://docs.stripe.com/api/payment_intents/create)を利用して決済処理を実行します。サーバー側で決済する場合、`confirm`パラメータを`true`にし、3Dセキュアの追加認証などが必要な場合に備えて、`return_url`も設定しましょう。

```js
    const pm = await retrieveCustomerPaymentMethod(customerId, paymentMethodId)
    const paymentIntent = await stripe.paymentIntents.create({
      confirm: true,
      amount: 100,
      currency: 'jpy',
      payment_method: pm.id,
      customer: customerId,
      return_url: 'https://example.com'
    })
    if (!paymentIntent.next_action) {
      return c.json({
        message: '支払い処理が完了しました',
        paymentIntentId: paymentIntent.id
      })
    }
    return c.json({
      message: '追加の認証処理が必要です',
      nextAction: paymentIntent.next_action,
      paymentIntentId: paymentIntent.id
    })
```

この処理で支払いが完了した場合は、追加の処理は必要ありません。しかしもしAPIレスポンスの`next_action`が`null`ではない場合は、まだ決済が完了していません。3Dセキュアの認証など、ユーザー側で追加のアクションが必要な状態ですので、メールやアプリケーション上の通知UIなどを利用して、追加操作を依頼しましょう。

## 追加認証は、リダイレクトまたはSDKで対応する

作成したPayment Intentの`next_action`には、2種類のデータが含まれます。Payment Intentを作成した際に`return_url`を設定した場合は、`type=redirect_to_url`のNext Actionオブジェクトが含まれます。この場合オブジェクトに含まれるリダイレクトURLを利用した追加認証フローをリクエストされています。顧客にメールやSMS・LINEなどで追加操作を依頼する際、オブジェクトに含まれる`url`へアクセスするように伝えましょう。`url`へアクセスし、追加認証処理が完了した場合、顧客は`return_url`のURLへリダイレクトされます。

```js
{
    "redirect_to_url": {
      "return_url": "https://example.com",
      "url": "https://hooks.stripe.com/3d_secure_2/hosted?merchant=xxxxxx"
    },
    "type": "redirect_to_url"
  }
```

`return_url`を設定しなかった場合は、[Stripe.js SDK](https://docs.stripe.com/js)を利用した追加認証ページを用意します。もしreturn_urを削除したことでエラーが発生した場合は、利用しているAPIバージョンが最新版ではないか、Stripeの提供する「動的な決済手段」が有効化されていない可能性があります。[Stripe Docsの手順](https://docs.stripe.com/payments/payment-methods/dynamic-payment-methods#migrate-to-dynamic-payment-methods)を確認して、動的な決済手段を有効にしましょう。

```diff_javascript
    const paymentIntent = await stripe.paymentIntents.create({
      confirm: true,
      amount: 1000,
      currency: 'jpy',
      payment_method: pm.id,
      customer: customerId,
-     return_url: 'https://example.com'
    })
```

この場合は、`next_action.type`が`use_stripe_sdk`に設定されています。

```js
{
    "type": "use_stripe_sdk",
    "use_stripe_sdk": {
      "directory_server_encryption": {
        "algorithm": "RSA",
        "certificate": "-----BEGIN CERTIFICATE----
        ...
}
```

どちらの方法で決済されるかが不明なケースでは、`next_action.type`の値を利用してどちらも対応できるように実装しましょう。

```js
if (!paymentIntent.next_action) {
  return;
}
if (paymentIntent.next_action.type === 'use_stripe_sdk') {
  // Stripe.jsで使用するため、client_secretを取得する
  const paymentIntentClientSecret = paymentIntent.client_secret
  // 独自の追加認証ページへの案内を行う。
} else if (paymentIntent.next_action.type === 'redirect_to_url') {
  const redirectUrl = paymentIntent.next_action.redirect_to_url.url
  // URLへのアクセスを依頼するメールなどを送信する
}
```

:::note
**`use_stripe_sdk`に固定したい場合**

ネイティブアプリ（ React Native / Capacitorなどを含む ）もユーザーに提供する場合、アプリ内で動線を完結させるためにリダイレクトによる認証を回避することをお勧めします。この場合、Payment Intent APIで`use_stripe_sdk`パラメータを`true`に設定しましょう。

```diff_javascript
    const paymentIntent = await stripe.paymentIntents.create({
      confirm: true,
      amount: 1000,
      currency: 'jpy',
      payment_method: pm.id,
      customer: customerId,
+      use_stripe_sdk: true,
      return_url: 'https://example.com'
    })
```

これによって、`return_url`が設定されている場合でも、`next_action.type`が`use_stripe_sdk`に固定されます。

:::

### SDKを利用して追加認証を実行する

`next_action.type`が`use_stripe_sdk`の場合、追加認証操作を行うページが必要です。追加認証を実行する場合は、[Stripe.js](https://docs.stripe.com/js)が提供する[handleNextAction関数](https://docs.stripe.com/js/payment_intents/handle_next_action)を実行するページを用意しましょう。以下はシンプルなHTMLとJavaScriptを利用した実装サンプルです。サーバー側で作成されたPayment Intentの`client_secret`を利用して、3Dセキュアなどの追加認証を実行します。

```html
  <!DOCTYPE html>
  <html>
  <head>
    <script src="https://js.stripe.com/v3/"></script>
  </head>
  <body>  
    <div id="error-message"></div>
    <script>
      const stripe = Stripe('{pk_からはじまるStripe公開可能キー}');
      
      stripe.handleNextAction({
        clientSecret: "{PAYMENT_INTENTのclient_secret}"
      })
        .then(() => window.alert("認証が完了しました"))
        .catch(error => {
          console.table(error)
          const errorMessage = document.getElementById('error-message');
          errorMessage.textContent = error.code + ':' + error.message;
        })
    </script>
  </body>
  </html>
```

実際のコードでは、`handleNextAction`を実行する前にPayment Intentの`next_action`が`null`ではないかを検証しましょう。もしすでに認証が完了してるPayment Intentで`handleNextAction`を実行すると、`handleNextAction: The PaymentIntent supplied is not in the requires_action state.`エラーが発生します。

また、顧客以外のユーザーがこのPayment Intentを利用して認証処理を行わないために、このページにも認証処理やPayment Intentがアクセスしているユーザーであるかどうかの検証も行いましょう。

## `payment_intent.requires_action`Webhookイベントで処理をよりシンプルに

ここまでのサンプルコードでは、決済処理を実行した後に追加認証の有無などの判定も実装していました。コードの量や保守性をより高めるためには、決済処理と追加認証処理を分離することをお勧めします。

Stripe Webhookを使った場合、次のような条件分岐をWebhook APIへ設定します。

```js
const event = await stripe.webhooks.constructEventAsync( 
  body, 
  signature, 
  STRIPE_WEBHOOK_SECRET,
);
switch(event.type) {
  case 'payment_intent.requires_action': {
    const paymentIntent = event.data.object
    if (!paymentIntent.next_action) {
      console.log("追加アクションなし")
      break
    }
    if (paymentIntent.next_action.type === 'use_stripe_sdk') {
      // Stripe.jsで使用するため、client_secretを取得する
      const paymentIntentClientSecret = paymentIntent.client_secret
      console.log("Stripe.jsを使った追加認証処理をユーザーに案内する")
      break
    }
    if (paymentIntent.next_action.type === 'redirect_to_url') {
      const redirectUrl = paymentIntent.next_action.redirect_to_url.url
      // URLへのアクセスを依頼するメールなどを送信する
      console.log("URLへのアクセスを依頼するメールなどを送信する")
      break
    }
    break
  }
  default:
      break
}
```

このWebhook実装を追加すると、決済処理側から追加認証に関する処理を取り除くことができます。これによって、アプリケーションや関数、コードが記載されたファイルの責務がより明確になり、コードの複雑性をなくすことができます。

```diff_javascript
    const pm = await retrieveCustomerPaymentMethod(customerId, paymentMethodId)
    const paymentIntent = await stripe.paymentIntents.create({
      confirm: true,
      amount: 100,
      currency: 'jpy',
      payment_method: pm.id,
      customer: customerId,
      return_url: 'https://example.com'
    })
-    if (!paymentIntent.next_action) {
-      return c.json({
-        message: '支払い処理が完了しました',
-        paymentIntentId: paymentIntent.id
-      })
-    }
-    return c.json({
-      message: '追加の認証処理が必要です',
-      nextAction: paymentIntent.next_action,
-      paymentIntentId: paymentIntent.id
-    })
```

また、Webhookを利用することで、将来別の決済フローを導入する必要が出た場合にも、追加認証に関する実装をコピーアンドペーストで転用する必要がなくなります。例えば[サブスクリプション決済での3Dセキュア認証](https://support.stripe.com/questions/using-3d-secure-authentication-with-payments-made-through-stripe-billing?locale=ja-JP)でも、`payment_intent.requires_action`イベントがトリガーされます。

:::note
サブスクリプションや請求書では、[ダッシュボードの設定](https://dashboard.stripe.com/settings/billing/automatic)にて、Stripeから追加認証をリクエストするメールを送信することもできます。利用しているサービスのコスト削減やインテグレーションをシンプルにされたい場合は、Stripeが提供する機能をご活用ください。

![スクリーンショット 2024-10-23 16.39.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/4b82f68c-de4b-fe65-9e9d-207328ec34df.png)


Stripeからメールを配信する場合、デザインや文言のカスタマイズ範囲が限定されます。そのため、サービスから送信するメールのデザインなどを統一されたい場合は、今回紹介したWebhook連携をご活用ください。

![スクリーンショット 2024-10-23 16.38.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/3cb2e815-f854-f926-e86e-bea18dcd756f.png)
:::

## まとめ

ユーザーの利便性とセキュリティを高めるため、ECサイトなどにおいてユーザーのクレジットカード情報を保存する仕組みは欠かせません。保存したクレジットカード情報を決済に利用することで、顧客の手間を減らすだけでなく、決済フォームを改ざんしてクレジットカード情報を窃取されるリスクも軽減できます。

しかし一方で保存したクレジットカード情報を決済へ利用するには、3Dセキュアのような決済プロバイダーやカードネットワークが要求する追加認証フローをシステムに実装する必要があります。決済処理時にアプリケーションが受け取った追加認証リクエストをどのようにユーザーへ通知し、認証を実施するか。場合によってはリダイレクト処理も発生するため、注意深く要件定義やテストを行わなければなりません。

Stripeでは、サーバー側・フロントエンド向けそれぞれのSDKを活用することで、できるだけ少ない追加コードでこのような追加認証処理を実装できます。`handleNextAction`関数のような便利なSDK機能を活用して、セキュアでユーザーが感じるストレスの少ない決済フローを実現しましょう。


## Stripeでの3Dセキュア義務化対応について知りたい方は、こちらもどうぞ

2025年3月より始まる3Dセキュア義務化に関する記事を複数用意しております。現在対応方法をお探し中の方は、ぜひこちらの記事もご覧ください。

https://qiita.com/hideokamoto/items/7920051d7476439e5a5e

https://qiita.com/hideokamoto/items/710f9f006fe59ab83b27

https://qiita.com/hideokamoto/items/0f6d4e5fa34e8e673140

https://qiita.com/hideokamoto/items/fa8223470ad75f5307d1

https://qiita.com/hideokamoto/items/c30eded3fa8cd73f1a93