【Rails】Stripeの実装（都度カード情報入力版）

# 環境

- Rails 5.2.3
- [stripe-ruby](https://github.com/stripe/stripe-ruby) 4.21.3
  - ドキュメント作成時（2019年8月頃）の最新版 

# ドキュメント（Stripe公式）

- [支払いの作成](https://stripe.com/docs/charges)
- [API Reference](https://stripe.com/docs/api)
  - [エラーハンドリング](https://stripe.com/docs/api/errors/handling?lang=ruby) ※画面右のコード例はプログラミング言語によって切り替え出来る。

# 設定

### KEYの設定（環境変数）
環境変数に以下を設定（以下は、`dotenv`への設定例）。

```ruby:.envrc
export STRIPE_SECRET_KEY=sk_test_4eC39HqLyjWDarjtT1zdp7dc
export STRIPE_PUBLISHABLE_KEY=pk_test_TYooMQauvdEDq54NiTphI7jx
```

`STRIPE_SECRET_KEY`、`STRIPE_PUBLISHABLE_KEY`は、以下の 3種類がある。

- 完全にパブリックなキー (A)
- ユーザ固有のキー（アカウント登録をしたら、Stripeのdashboardで取得可能）
  - テスト用 (B)
  - 本番 (C)

(A)でもテスト（Stripeのサイトにリクエストを投げてレスポンスを受け取ること）は可能。(A)と(B)の違いは、(B)であればStripeのdashboardにログが残る。

## initializersの設定
↑で設定した`STRIPE_SECRET_KEY`と`STRIPE_PUBLISHABLE_KEY`を読み込む。

```ruby:config/initializers/stripe.rb
Rails.configuration.stripe = {
  secret_key: ENV['STRIPE_SECRET_KEY'],
  publishable_key: ENV['STRIPE_PUBLISHABLE_KEY'],
}

Stripe.api_key = Rails.configuration.stripe[:secret_key]
# 以下は必要に応じて。
# Stripe.log_level = Stripe::LEVEL_DEBUG if Rails.env.development? || Rails.env.test?
```

# コンソールでテスト

テスト結果は、[stripeのダッシュボード](https://dashboard.stripe.com/logs)で確認可能。
※ユーザ固有のキーを使っている場合のみ。
※ダッシュボードで 「テストデータを表示」 をチェックすること。

## リクエスト例
  - amountはドル換算で 50セント以上になるようにする（50セント未満の場合、exceptionが発生する）。
  - tokenは以下の何れかを参照 （※以下の例では`tok_jp`を使用している）。
     - https://stripe.com/docs/testing#cards
     - https://stripe.com/docs/testing#international-cards （※`currency`として`jpy`を使う場合はこっち。但し、テストなのでどちらもで良い）
  - `metadata`はオプションで、任意のキーバリューを登録可能。
  - 詳細は[こちら](https://stripe.com/docs/charges)。

```ruby
> amount = 500
> token = 'tok_jp'
> order_id = 'sample_order_id'
> charge = Stripe::Charge.create(
    amount: amount,
    currency: 'jpy',
    source: token,
    metadata: { order_id: order_id },
  )
```

## レスポンス例（↑の例では`charge`に入るもの）

```ruby
charge.class
=> Stripe::Charge
```

```ruby
#<Stripe::Charge:0x3fcffca797f0 id=ch_1F2xIk2eZvKYlo2CQKTDhT9r> JSON: {
                        :id => "ch_1F1p6aH3tpHGjL9VSxp7q6sc",
                    :object => "charge",
                    :amount => 50,
           :amount_refunded => 0,
               :application => nil,
           :application_fee => nil,
    :application_fee_amount => nil,
       :balance_transaction => "txn_1F1p6bH3tpHGjL9V0HnBtcyF",
           :billing_details => {
        :address => {
                   :city => nil,
                :country => nil,
                  :line1 => nil,
                  :line2 => nil,
            :postal_code => nil,
                  :state => nil
        },
          :email => nil,
           :name => nil,
          :phone => nil
    },
                  :captured => true,
                   :created => 1564469200,
                  :currency => "jpy",
                  :customer => nil,
               :description => "foo",
               :destination => nil,
                   :dispute => nil,
              :failure_code => nil,
           :failure_message => nil,
             :fraud_details => {},
                   :invoice => nil,
                  :livemode => false,
                  :metadata => {
        :order_id => "sample_order_id"
    },
              :on_behalf_of => nil,
                     :order => nil,
                   :outcome => {
        :network_status => "approved_by_network",
                :reason => nil,
            :risk_level => "normal",
            :risk_score => 46,
        :seller_message => "Payment complete.",
                  :type => "authorized"
    },
                      :paid => true,
            :payment_intent => nil,
            :payment_method => "card_1F1p6aH3tpHGjL9VgCk3up1H",
    :payment_method_details => {
        :card => {
                     :brand => "visa",
                    :checks => {
                      :address_line1_check => nil,
                :address_postal_code_check => nil,
                                :cvc_check => nil
            },
                   :country => "JP",
                 :exp_month => 7,
                  :exp_year => 2020,
               :fingerprint => "nU5DqM3V4oA3mRpV",
                   :funding => "credit",
                     :last4 => "0003",
            :three_d_secure => nil,
                    :wallet => nil
        },
        :type => "card"
    },
             :receipt_email => nil,
            :receipt_number => nil,
               :receipt_url => "https://pay.stripe.com/receipts/acct_1Efh0qH3tpHGjL9V/ch_1F1p6aH3tpHGjL9VSxp7q6sc/rcpt_FWssev6e7emJfUcBYqnzY0hWdl0D6ox",
                  :refunded => false,
                   :refunds => {
             :object => "list",
               :data => [],
           :has_more => false,
        :total_count => 0,
                :url => "/v1/charges/ch_1F1p6aH3tpHGjL9VSxp7q6sc/refunds"
    },
                    :review => nil,
                  :shipping => nil,
                    :source => {
                         :id => "card_1F1p6aH3tpHGjL9VgCk3up1H",
                     :object => "card",
               :address_city => nil,
            :address_country => nil,
              :address_line1 => nil,
        :address_line1_check => nil,
              :address_line2 => nil,
              :address_state => nil,
                :address_zip => nil,
          :address_zip_check => nil,
                      :brand => "Visa",
                    :country => "JP",
                   :customer => nil,
                  :cvc_check => nil,
              :dynamic_last4 => nil,
                  :exp_month => 7,
                   :exp_year => 2020,
                :fingerprint => "nU5DqM3V4oA3mRpV",
                    :funding => "credit",
                      :last4 => "0003",
                   :metadata => {},
                       :name => nil,
        :tokenization_method => nil
    },
           :source_transfer => nil,
      :statement_descriptor => nil,
                    :status => "succeeded",
             :transfer_data => nil,
            :transfer_group => nil
}
```

# エラー処理

- エラーがあった場合、exception（`Stripe::InvalidRequestError`）が発生する。
  - 以下は、amountが50cents以下だった場合の例。

```ruby
Stripe::InvalidRequestError: Amount must convert to at least 50 cents. ¥50 converts to approximately $0.45.
from /Users/foo/src/github.com/bar/baz/vendor/bundle/ruby/2.6.0/gems/stripe-4.21.3/lib/stripe/stripe_client.rb:350:in `handle_error_response'
Caused by Faraday::ClientError: the server responded with status 400
from /Users/foo/src/github.com/bar/baz/vendor/bundle/ruby/2.6.0/gems/faraday-0.15.4/lib/faraday/response/raise_error.rb:13:in `on_complete'
```

# 独自Exception

「amountの最低金額を500円にする」等、独自でExceptionを発生させる例。

```ruby
MINIMUM_AMOUNT = 500
raise Stripe::InvalidRequestError.new('Amount must be more than or equal to 500', amount) if amount < MINIMUM_AMOUNT
```

- `Stripe::InvalidRequestError`の[initialize](https://www.rubydoc.info/github/stripe/stripe-ruby/Stripe/InvalidRequestError#initialize-instance_method)参照

# 実装時の注意点

## クライアントから送られてきた金額は、サーバで検証する（改竄されうるため）。
- クライアントからamount（請求額）を受け取る。
- オーダー情報（※この記事の例ではorder_id）を元にサーバでamountを計算する。
  - 上記 2つが異なる場合は、改竄の疑いがあるためエラーを返す。

## 二重課金の防止 
- [Idempotent Requests](https://stripe.com/docs/api/idempotent_requests) 

# おまけ

フリーランスで活動しています。
開発案件（Go、Rails、Blockchain、機械学習など）をお待ちしております。

[職務経歴書（wantedlyへのリンク）](https://www.wantedly.com/users/36882295)