アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

Stripe + Vue.js による決済フォームの実装

morishitaです。

先日、決済サービス Stripeについて書きました。

tech.actindi.net

今回はVue.jsStripeを使ってクレジットカードの決済フォームを実装したのでご紹介します。

なおこのエントリのコードはTypeScriptを使っています。
HTML部分は Slim または Pug を使っています。

Stripeが提供するもの

StripeではCheckout.jsというJavaScriptのスクリプトが用意されていて、ほとんどコードを書かなくてもクレジットカードフォームを作ることができます。

ですが、デザインの自由度を重視すると自前で実装することになります。

その場合にはStripe ElementsというJavaScriptのUIコンポーネントを組み込んで実装します。 これを使えば、クライアントサイドでできるカード番号のベリファイとか不正な有効期限のチェックをやってくれます。
その上、Stripeではクレジットカード番号を送信してTokenを取得しないと決済できないのですが、そのTokenを取得する処理もメソッドを呼び出すだけでやってくれます。

また、stripe/react-stripe-elementsというStripe Elements の Reactコンポーネントを公開されており、それを使うという選択肢もありました。
しかし、以下の理由で今回はJavaScriptのフレームワークとしては Vue.jsを採用しました。

  • Vue.jsは別プロダクトですでに導入実績がある
  • 当社ではUIのHTML部分(Slim,Pug)はデザイナーが直接コーディングする

これらの状況から Vue.js のほうがハードルが低いと考えたのです1

Vue.jsによる実装

ということで、以下にVue.jsを使ったStripeのクレジットカードフォームの実装について説明します。

マウントポイントとなるHTMLコード

まず、コンポーネントのマウントポイントとなるHTML部分のコード(Slim)です。
この例ではID#charge-formの要素にコンポーネントがマウントすることにします。

/ Stripeのライブラリ読み込み
script src='https://js.stripe.com/v3/'
/ Vueコンポーネントのマウントスクリプトの読み込み
= javascript_packs_with_chunks_tag 'charge_form'

#charge-form(
  data-action=charges_path
  data-stripe-key=Rails.configuration.stripe[:publishable_key]
)

ポイントはscript src='https://js.stripe.com/v3/'でStripeのJSを読み込んでいるところ。後にStripeのTokenを取得するために必要です2

そして、後述しますがこの要素のactionstripeKeyをdata属性はコンポーネントのプロパティとしてセットする値となります。

コンポーネントのマウントスクリプト

続いて、Vueコンポーネントをマウントするためのスクリプトです。

後述するVueコンポーネントCardForm.vueDOMContentLoadedイベントのタイミングでマウントします。

そのコード(Typescript)が次のとおりです。

import Vue from 'vue';
import CardForm from 'components/CardForm.vue';

document.addEventListener('DOMContentLoaded', () => {
  const node = document.querySelector<HTMLElement>('#charge-form');
  const { action, stripeKey } = node.dataset;
  const authenticityToken = document.querySelector<HTMLMetaElement>("[name='csrf-token']").content;
  // Stripeインスタンスの生成。Stripeはhttps://js.stripe.com/v3/に含まれる
  const stripe: stripe.Stripe = Stripe(stripeKey); 

  new Vue({
    render: h => h(CardForm, {
      props: { action, authenticityToken, stripe },
    }),
  }).$mount('#charge-form');
});

このコードのポイントは、Vueコンポーネントを初期化する前にプロパティとしてセットする値を用意しているところ。
ID#charge-formの要素のdata属性からactionstripeKeyを取り出しています。 さらに<meta name="csrf-token">からはRailsのauthenticity_tokenを取り出しています3

特に重要なのはStripe(stripeKey)でStripeインスタンスを生成して、それをVueコンポーネントに渡しているところです。
ブラウザで使うStripeインスタンスはhttps://js.stripe.com/v3/由来である必要があるのでこうした渡し方をします。

これらをプロパティにセットしてVueコンポーネントを初期化します。

クレジットカードフォームのコンポーネント

最後にクレジットカードフォームのコンポーネントについて説明します。 これはVueの単一ファイルコンポーネントとして実装したシンプルなクレジットカードフォームです。
テンプレートは Pug で実装し、スクリプトはTypescriptで vue-property-decoratorを利用したクラススタイルの実装になっています。

構成要素は次の4つ。

  • カード番号
  • 有効期限
  • セキュリティコード
  • サブミットボタン

サブミットボタン以外の入力要素には Stripe Elementsのコンポーネントを利用します。 サブミットボタンは Stripe Elements のコンポーネントの状態に応じてdisabledのtrue,falseを制御しています。

<template lang="pug">
.charge-form
  div.error(v-if="errorMessage")
    .message {{errorMessage}}
  form(accept-charset="UTF-8" method="post" ref="cardForm" :action="action")
    div クレジットカード情報
      .fieldset.card
        div カード番号 {{cardBrand}}
        div
          #card-number.field(ref="cardNumber")
          .error-message {{inputError('cardNumber')}}
        div 有効期限
        div
          #card-expiry.field(ref="cardExpiry")
          .error-message {{inputError('cardExpiry')}}
        div セキュリティコード
        div
          #card-cvc.field(ref="cardCvc")
          .error-message {{inputError('cardCvc')}}
      .fieldset
        input(type="hidden" name="stripe_token" value ref="stripeToken")
        input(type="hidden" name="authenticity_token" :value="authenticityToken")
    div
      button(
        type="submit"
        @click="submit"
        :disabled="!canSubmit || disableInput"
        :class="{ 'btn-gray': disableInput, 'btn-orange': canSubmit }"
      ) 決済する
</template>

<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator';

// Stripe Elementsのスタイルなどの設定
// see https://stripe.com/docs/stripe-js/reference#the-elements-object
const elementsOptions: stripe.elements.ElementsOptions = {
  classes: {
    base: 'base',
    focus: 'focus',
    empty: 'empty',
    invalid: 'invalid',
  },
  style: {
    base: {
      color: '#33291f',
      fontWeight: 600,
      fontFamily: 'Helvetica,Arial,sans-serif',
      fontSize: '16px',
      fontSmoothing: 'antialiased',
      ':focus': { color: '#f55c06' },
      '::placeholder': { color: '#cac4be' },
      ':focus::placeholder': { color: '#dddddd' },
    },
    invalid: { color: '#cc0000' },
  },
};

// Vueコンポーネント
@Component
export default class CardForm extends Vue {
  @Prop(String) readonly action!: string;
  @Prop(String) readonly authenticityToken!: string;
  @Prop(Object) readonly stripe!: stripe.Stripe; // Stripeクライアントオブジェクト

  private elements = this.stripe.elements(); // Stripe Elements の取得
  private formStatus: 'canInput'|'submitting' = 'canInput';

  // フォームの入力状態の管理オブジェクト。
  private inputStatus: { [key: string]: { [key: string]: string | boolean | undefined } } = {
    cardNumber: {
      error: undefined,
      empty: true,
      complete: false,
      brand: undefined,
    },
    cardExpiry: { error: undefined, empty: true, complete: false },
    cardCvc: { error: undefined, empty: true, complete: false },
  };

  // Stripe Elementsのインスタンス生成
  private cardNumber = this.elements.create('cardNumber', elementsOptions);
  private cardExpiry = this.elements.create('cardExpiry', elementsOptions);
  private cardCvc = this.elements.create('cardCvc', elementsOptions);

  private stripeToken: string | null = null;

  // フォームの入力状態からサブミット可能か否かを判定する
  get canSubmit(): boolean {
    return Object.values(this.inputStatus).every(event => event.complete);
  }

  // クレジットカードブランドを抽出する
  get cardBrand(): string | boolean {
    const brand = this.inputStatus.cardNumber && this.inputStatus.cardNumber.brand;
    if (brand && brand !== 'unknown') {
      return brand;
    }
    return null;
  }

  // フォームに入力できない状態を判定する
  get disableInput(): boolean {
    return this.formStatus === 'submitting';
  }

  get inputError(): (elementType: string) => string | boolean {
    const lastEvents = this.inputStatus;
    return (elementType: string): string | boolean => {
      const lastEvent = lastEvents[elementType];
      return lastEvent && lastEvent.error;
    };
  }

  // ライフサイクルフックメソッド。
  private mounted(): void {
    // Stripe Elements のイベントをキャプチャして各入力の状態をinputStatusに反映する
    // イベントハンドラを仕込む
    const lastEvents = this.inputStatus;
    [this.cardNumber, this.cardExpiry, this.cardCvc].forEach((element) => {
      element.on('change', (event) => {
        lastEvents[event.elementType].error = event.error && event.error.message;
        lastEvents[event.elementType].empty = event.empty;
        lastEvents[event.elementType].complete = event.complete;
        lastEvents[event.elementType].brand = event.brand;
      });
    });
    // Stripe Elementsの各コンポーネントインスタンスをマウントする
    this.cardNumber.mount(this.$refs.cardNumber);
    this.cardExpiry.mount(this.$refs.cardExpiry);
    this.cardCvc.mount(this.$refs.cardCvc);
  }

  // フォームサブミット時の処理
  private async submit(e): Promise<void> {
    e.preventDefault();
    this.formStatus = 'submitting';
    const cardForm = this.$refs.cardForm as HTMLFormElement;

    // Stripe Tokenの取得
    const { token, error } = await this.stripe.createToken(this.cardNumber);
    if (token) {
      (this.$refs.stripeToken as HTMLInputElement).value = token.id;
      cardForm.submit(); // フォームのサブミット
    } else {
      this.errorMessage = error.message;
    }
  }
}
</script>

ポイントは次の2点。

  • コンポーネントのプロパティとしてStripeインスタンスを受け取っているところ
  • mounted()メソッドでStripe Elementsのコンポーネントをマウントするところ

このコンポーネントではHTMLフォームをsubmitしてページさせています。SPAに組み込むなら必要なのはStripe Token を送信することなので、それを受け取るサーバサイドAPIを設けて渡せばいいと思います。

ユーザが購入確定(サブミット)したあとの処理の流れはサーバサイドも合わせて次のようになります。

  • [Client] ブラウザ側で Stripe#createToken を使ってTokenの取得
  • [Client] そのトークンの自サービスのサーバへの送信
  • [Server] サーバサイドでTokenを受け取り、Stripeへの決済処理
  • [Server] 決済結果に応じて、自サービス側の処理 (在庫引当確定やユーザへの取引成立の通知など)
  • [Server] ブラウザにレスポンスを返す

この中でStripeのAPIを2回叩くことになるので自社側の処理も合わせるとユーザを待たせる時間が割とできてしまいます4
実際のプロダクトでは処理中であることを知らせユーザを安心させつつ、待たされている感を軽減する表示も必要となるでしょう。

テストコード

さて、最後にこのコンポーネントのユニットテストについて記述します。
今回はJestをテストフレームワークとして使います。

簡単なテストしかしてないですが、サンプルコード(Typescript)は次のとおりです。

import Vue from 'vue';
import { mount } from '@vue/test-utils';
import CardForm from 'components/CardForm.vue';

// Stripeインスタンスのモックを作る
function stripeMock() {
  const elementMock = {
    mount: jest.fn(),
    on: jest.fn(),
  };
  const elementsMock = {
    create: jest.fn().mockReturnValue(elementMock),
  };
  return {
    elements: jest.fn().mockReturnValue(elementsMock),
    createToken: jest.fn().mockResolvedValue({ token: { id: 'tok_12345678' }, error: null }),
  };
}

describe('CardForm.vue', () => {
  let propsData;
  beforeEach(() => {
    propsData = {
      action: '/charges',
      authenticityToken: '',
      stripe: stripeMock(),
    };
  });

  test('表示できる', () => {
    const wrapper = mount(CardForm, { propsData });
    expect(wrapper.exists()).toBeTruthy();
    expect(wrapper.find({ ref: 'cardForm' }).isVisible()).toBeTruthy();
  });

  test('何も入力されていない状態ではサブミットボタンはdisabled', () => {
    const wrapper = mount(CardForm, { propsData });
    const submitButton = wrapper.find('button[type="submit"]');
    expect(submitButton.attributes().disabled).toBeTruthy();
  });

  describe('カード番号を入力した場合', () => {
    let wrapper;
    beforeEach(() => {
      wrapper = mount(CardForm, { propsData });
      // inputStatusを上書きして入力をエミュレート
      wrapper.setData({
        inputStatus: {
          cardNumber: { complete: true },
          cardExpiry: { complete: true },
          cardCvc: { complete: true },
        },
      });
    });

    test('サブミットボタンクリックでdisabled', () => {
      const submitButton = wrapper.find('button[type="submit"]');
      expect(submitButton.attributes().disabled).toBeUndefined(); // クリック前はenable
      submitButton.trigger('click');
      expect(submitButton.attributes().disabled).toBeTruthy(); // クリック後は disabled
    });
  });
});

ポイントはコンポーネントの外部から与えるStripeインスタンスのモックを作るstripeMock()関数です。
アクセスする可能性のあるプロパティはほぼjest.fn()でモック関数をあてがいます。 createTokenメソッドだけはStripe Tokenを返すようにモックしています。

あとは、Vue Test Utilsを使った普通のVueコンポーネントのテストです。

まとめ

いこーよのチケット販売サービス「すぐいこ」では決済処理にStripeを利用しています。
Vue.jsを利用してStripeのフォームを実装する方法を紹介しました。
Jestでテストもしっかりできます。

最後に

アクトインディではエンジニアを募集しています。

actindi.net


  1. Vue(Nuxt.js含む)はすでにいこレポこどもバースデーで導入し、活用しています。当社のデザイナは普段からRailsアプリケーション上でSlimによるコードディングに慣れていて、Vueを使ったプロダクトでも PugでUIを実装してもらっています。
    ReactだとUIの実装はJSXになりちょっとデザイナには難しいかなと思います。
    babel-plugin-transform-react-pugを使えばPugでも実装できそうですが、結局JSのコードの中に埋もれている感じは残ります。様々な技術要素になれるために時間を使うよりもデザイナにはきれいで使いやすいUIの開発に集中してもらいたいと思っています。
    その点、Vueの単一ファイルコンポーネントなら<template><style>にUIの見た目の部分が抽出されています。そのためデザイナにもパッと見とっつきやすそうでに見え、これまでの導入ではそれほど難しそうではありませんでした(あくまで私視点ではそう思っているということですが…)。

  2. stripe - npmもありますが、これはサーバサイドで使うためライブラリとなっておりブラウザ上ではhttps://js.stripe.com/v3/を読み込む必要があります。

  3. POSTされるフォームデータを処理する際、CSRFを防止するのに使います。Rails標準のアレです。

  4. カード番号をStripeに保存させようとするとCustomerを作るAPIも叩くので更に処理時間が増えます。