クレジットカードのセキュリティコード入力エラーを、Stripeで効率的にハンドルする方法

クレジットカード決済は、オンラインサービスやEC事業において欠かせない要素となっています。しかしながら、カード情報の入力ミスは避けられないリスクであり、中でもセキュリティコードの間違いは最も一般的な入力エラーの一つです。

セキュリティコードは、クレジットカードの不正利用を防ぐための重要な情報です。ユーザーがこの番号を誤って入力してしまうと、決済が完了せず、購入やサービス利用に支障をきたすことになります。このようなエラーが発生した場合、適切に対処しないと売上げの機会損失につながるだけでなく、ユーザー体験を大きく損なう恐れがあります。

本記事では、セキュリティコードの入力ミスへの対処方法について、Stripeを例に具体的に解説していきます。

## クレジットカードのセキュリティコードとは

クレジットカードのセキュリティコードとは、カードの有効性を確認するための3または4桁の番号のことです。一般的には「CVCコード」「CVVコード」と呼ばれています。Visaカードの場合はCVV(Card Verification Value)、MasterCardの場合はCVC(Card Validation Code)と表記されます。これらのコードは、カード番号とは別に印字されており、不正利用を防ぐ重要な情報となっています。

## セキュリティコードの入力ミスを、正しくハンドルする

![スクリーンショット 2024-05-20 15.10.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/29606fdc-d543-5d5f-5a9c-faa3a3fb2488.png)

セキュリティコードの入力ミスが発生した場合、ユーザーに分かりやすいエラーメッセージを表示し、カード情報の再入力を促すことが求められます。単にエラーが発生したと伝えるだけでなく、セキュリティコードに間違いがあることを明示することで、ユーザーがスムーズに対処できるようになります。また、エラー画面でカード情報を一部表示したり、セキュリティコード入力欄を強調表示したりするなど、UIの工夫も効果的です。再入力の手間を最小限に抑え、ストレスなく決済を完了できるようサポートすることが大切です。

## Stripeでセキュリティコードの入力ミスをハンドルする方法

ここからはStripeで決済を組み込む際に、セキュリティコードのエラーをテストする方法を紹介します。まずはテスト環境でセキュリティコードのエラーを発生させる方法について見ていきましょう。

### Stripeでセキュリティコードの入力ミスをテストする方法

Stripeのテスト環境では、「Stripeが用意するテスト用のクレジットカード番号」を利用して決済をテストします。`4242424242424242`は有名なテストカード番号の１つで、ステッカーや専用のキーガジェットを作っている方もいます。

https://x.com/sammcallister/status/1379820650668429320

https://x.com/onurgenes/status/1522590128484540419

https://docs.stripe.com/testing

多くのテスト用カードでは、どんな数字をセキュリティコードに入力しても決済が成功します。もし「セキュリティコードが誤っている場合」をテストしたい場合は、専用の **クレジットカード番号:`4000000000000127`** を利用しましょう。このカード番号を使用して決済を試みると、「セキュリティコードが正しくない」というエラーを発生させることができます。

![スクリーンショット 2024-05-20 15.30.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/a0cac1b1-2ef4-36a2-fe8e-5278f246f160.png)

### [リダイレクト・埋め込み] Stripe Checkoutで、追加作業なしに処理する

Stripeで決済フローを実装する場合、「顧客をStripeがホストするUIにリダイレクトさせる」か「サイトにStripeの決済UIを埋め込むか」の2つを選ぶことができます。Stripe Checkoutはリダイレクト・埋め込みどちらにも対応できるプロダクトで、CVRや決済成功率を高めるための改善施策や機能追加の恩恵をもっとも受けやすいプロダクトの１つでもあります。

Stripe Checkoutでセキュリティコードの入力ミスに対応する方法はとても簡単です。なぜならセキュリティコードの入力ミスや住所・カード番号などのバリデーション機能は、デフォルトで組み込みされていますので、**ただCheckoutの組み込みを行うだけ**で対応が完了します。

```js
app.post('/checkcout', async c => {
  const { STRIPE_SECRET_API_KEY, STRIPE_WEBHOOK_SECRET } = env<Env>(c)
  const stripe = new Stripe(STRIPE_SECRET_API_KEY, {
    apiVersion: '2023-08-16'
  });
  const session = await stripe.checkout.sessions.create({
    line_items: [{
      price: 'price_xxxx',
      quantity: 1
    }],
    mode: 'payment',
    success_url: 'https://localhost:8787'
  })
  return c.redirect(session.url)
})
```

Stripe Checkout APIで決済フローを開始すると、Stripeの用意する決済フォームがユーザーに表示されます。

![スクリーンショット 2024-05-20 16.01.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/8d1e88ff-dc10-0b98-e511-adfc081a97e3.png)

この決済フォームにテスト用のクレジットカード番号: `4000000000000127`を入力してみましょう。[支払う]ボタンをクリックすると、セキュリティコードにエラーが発生していることを伝えるメッセージがフォームに表示されます。

![スクリーンショット 2024-05-20 15.10.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/29606fdc-d543-5d5f-5a9c-faa3a3fb2488.png)

Stripe Checkoutを利用して実装することで、クレジットカードの番号や有効期限だけでなく、セキュリティコードの検証についても簡単に対応できます。

https://docs.stripe.com/payments/checkout/how-checkout-works

https://docs.stripe.com/checkout/quickstart

### [埋め込み] Stripe Elementsで、セキュリティコードのエラーをハンドルする

ブランドやサービスにデザインガイドラインがある場合、Stripe Checkoutではそれらの要件を満たせないことがあります。その場合に利用するのが、決済フォームだけを埋め込むStripe Elementsです。

![スクリーンショット 2024-05-20 16.04.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/3062f29d-98e3-37cb-7bcf-736243fe9b24.png)

Stripe Elementsを利用する場合もCheckoutと同様で、まずサーバー側でStripe APIをよびだします。ここでは「カードの保存だけの場合は、`SetupIntents API`」「オーソリや決済を行う場合は、`PaymentIntents API`」を利用します。

```bash
curl https://api.stripe.com/v1/payment_intents \
  -u sk_test_YOUR_SECRET_KEY: \
  -d "amount=10000" \
  -d "currency=jpy"
```

このAPIを呼び出すと、`client_secret`を含むPaymentIntentsオブジェクトが取得できます。

```json
{
  "id":"pi_3PIQKuIDF6qBhttt0SGyiNcy",
  "object":"payment_intent",
  "amount":100,
  "client_secret": "pi_xxxxxx_secret_yyyyy"
...
```

この`client_secret`を利用して、アプリケーションで決済フォームを表示させましょう。アプリケーション側では、Stripe.jsを利用してフォームをHTMLにマウントします。

```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="card-element"></div>
      <button id="submit-button">注文する</button>
    </form>
  
    <script>
      const stripe = Stripe('${STRIPE_PUB_API_KEY}');

      // Stripe Elementsを初期化
      const elements = stripe.elements({
        clientSecret: "${seti.client_secret}"
      });
      const cardElement = elements.create('payment');
      cardElement.mount('#card-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.confirmPayment({
          elements,
          confirmParams: {
            return_url: 'https://localhost:8787/pe',
          },
        });
      });
    </script>
  </body>
  </html>
```

決済を完了させる処理である`stripe.confirmPayment`は、セキュリティコードの間違いやクレジットカードの残高不足・不正利用の疑いが出た場合などでは、決済を完了させずにエラーを返します。エラーの中身を利用することで、フォーム内にエラーメッセージを出力したり、モーダルやアラートなどを利用して通知を行うなどの制御が自由に行えます。

```diff_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="card-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: "${seti.client_secret}"
      });
      const cardElement = elements.create('payment');
      cardElement.mount('#card-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.confirmPayment({
          elements,
          confirmParams: {
            return_url: 'https://localhost:8787/pe',
          },
        });
      
+        if (result.error) {
+          const errorMessage = document.getElementById('error-message');
+          errorMessage.textContent = result.error.code + ':' + result.error.message;
+        }
      });
    </script>
  </body>
  </html>
```

決済フォームにテスト用のクレジットカード番号: `4000000000000127`を送信すると、こちらもセキュリティコードに問題があることを示すエラーメッセージが画面に表示されました。

![スクリーンショット 2024-05-20 16.17.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/33fb2964-369d-dd92-0d05-e10ff0b81bf8.png)

エラーオブジェクトには、「クレジットカードのどの項目に問題が起きているか」などの追加情報も含まれています。この情報を利用して、ユーザーにストレスのないカード再入力フローを構築することができます。

![スクリーンショット 2024-05-20 16.18.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/c57e69e0-ce0f-9d4c-2151-02dbd94a6cd3.png)

## 「カード情報の保存」だけを実施する際の注意点

Stripeでは、決済処理やオーソリだけでなく、「カード情報の保存」だけを実装することもできます。その場合にもCheckoutやElementsを利用することができ、PaymentIntents APIとほとんど同じ使い勝手でSetupIntents APIを利用できます。

ただし現在では非推奨になっているSource APIを利用されている場合は、**サーバー側での追加対応**も必要になりますので、ご注意ください。

### 非推奨のSource APIでは、自前でエラーハンドリングが必要

Source APIを利用した組み込みでカード情報の保存を行う場合、セキュリティコードのエラーは**サーバー側で発生**します。そのため、以下のNode.js( Hono )で作ったAPIのように、「カード情報に問題があるエラーか、システムが原因のエラーかを判定する処理」が必要になります。

```diff_javascript

app.post('/save-card', async c => {
  const { STRIPE_SECRET_API_KEY } = env<Env>(c)
  const stripe = new Stripe(STRIPE_SECRET_API_KEY, {
    apiVersion: '2023-08-16'
  });
  const { source } = await c.req.json<{
    source: string;
  }>()

  try {
    // ソースIDを使ってStripe上でカード情報を保存
    const customer = await stripe.customers.create({
      source: source,
    });

    console.log('Customer created:', customer.id);
    return c.json(customer)
  } catch (error) {
    console.error('Error:', error);
+    if (error.code === 'incorrect_cvc') {
+      c.status(400)
+      return c.json(error)
+    }
    c.status(500)
    return c.json(error)
  }
})
```

また、「Stripe.js側で発生したエラー」と「サーバー側で発生したバリデーションエラー」の2つをハンドルする必要がでてきます。このため、現在提供している組み込みと比較してエラーハンドリングやユーザーへの案内の表示制御が複雑化しやすくなっています。

```diff_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="card-element"></div>
      <button id="submit-button">Save Card</button>
    </form>
  
    <script>
      const stripe = Stripe('${STRIPE_PUB_API_KEY}');

      // Stripe Elementsを初期化
      const elements = stripe.elements();
      const cardElement = elements.create('card');
      cardElement.mount('#card-element');

      // フォームの送信イベントを処理
      const form = document.getElementById('payment-form');
      form.addEventListener('submit', async (event) => {
        event.preventDefault();

        const { source, error } = await stripe.createSource(cardElement);

        if (error) {
          console.error('Error:', error);
        } else {
          // ソースIDをサーバーに送信
          const response = await fetch('/save-card', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ source: source.id }),
          });

+          if (response.ok) {
+            console.log('Card information saved successfully');
+          } else {
+            console.error('Error saving card information');
+          }
        }
      });
    </script>
  </body>
  </html>
```

:::note warn
**Source APIは、現在の3Dセキュア認証（EMV 3Dセキュア）に対応していない**

また、Source APIを利用している場合、カード情報を入力する際に3Dセキュアの認証フローを追加することができません。そのため、2025年3月からの3Dセキュア認証必須化への対応を行うためにも、この後紹介するSetupIntents APIへの移行を計画してください。
:::


### Setup Intents APIに移行して、エラー処理をStripeに任せよう

エラーハンドリングや3Dセキュアに対応するには、Source APIの代わりにSetupIntents APIを利用しましょう。実装時の変更点については、以下の移行ガイドを参考にしてください。

https://docs.stripe.com/payments/payment-element/migration?integration-path=future&locale=ja-JP

ガイドではCard ElementからPayment Elementへの移行方法も紹介していますが、Card Elementを使い続けたまま、SetupIntent APIに切り替える「段階的な移行」も可能です。その場合は、`stripe.confirmSetup`を利用する代わりに`stripe.confirmCardSetup`の利用を継続して実装しましょう。

```diff_javascript

      form.addEventListener('submit', async (event) => {
        event.preventDefault();

        // Trigger form validation and wallet collection
        const {error: submitError} = await elements.submit();
        if (submitError) {
          console.log(submitError);
          return;
        }
        const res = await fetch("/seti", {
          method: "POST",
          headers: {"Content-Type": "application/json"},
        });
      
        const {client_secret: clientSecret} = await res.json();
        const result = await stripe.confirmCardSetup(
          clientSecret,
          {
            payment_method: {
              card: elements.getElement('card')
            }
          }
        );
      
        console.log(result)
      });
```

Card ElementからPayment Elementへ移行する方法や、そのメリットについて興味がある方は、以下のドキュメントをご覧ください。

https://docs.stripe.com/payments/payment-element/migration?integration-path=future&locale=ja-JP

## まとめ

今回紹介したStripe CheckoutやElementsを利用することで、クレジットカード情報の誤入力にも少ない手間で対応できます。Checkoutを利用して決済フローの最適化をStripeに任せることで効率化が期待できますし、Elementsを利用した場合には、デザインガイドラインや独自のUXを提供するためのアプローチを試みることが可能です。

ただし現在では非推奨になっているSource APIを利用した場合には、エラーハンドリングを**サーバー側でも**実施する必要があります。サーバー側でエラーレスポンスを作成する処理を追加し、それを利用してUIにメッセージを表示させる必要があるため、SetupIntents　APIを利用した実装への移行をお勧めします。

## 追加の学習リソース

https://stripe.com/jp/resources/more/credit-card-declines

https://support.stripe.com/questions/cvc-collection-requirements?locale=ja-JP