stripe + Rails 7でサブスクリプション機能を実装してみた


stripeを使って、サブスクリプション機能に関する以下のユースケースを実装してみた。
- プレミアム会員になる
- プレミアム会員を退会する

![overview.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227279/9b0ccfb3-7527-d29c-396d-631e9e3a5ea7.gif)

# 前提条件

- stripeアカウントを持っている
- 以下のgemをGemfileに追加している
```:Gemfile
gem 'devise'
gem 'haml-rails'
gem 'stripe'
```
- `devise` gemにより、Userモデルを作成している
- Userモデルは以下の属性を持っている
  - customer_key:string
      - stripe上の顧客IDを表す属性
  - subscription_key:string
      - stripe上のサブスクリプションIDを表す属性
  - subscription_status:string
      - stripe上のサブスクリプションの状態を表す属性
- package.jsonに`@stripe/stripe-js`が追加されている

# stripeダッシュボードで商品を追加する
以下にアクセスして、商品を作成する(入力情報は以下の画像を参考)。
[https://dashboard.stripe.com/test/products/create](https://dashboard.stripe.com/test/products/create)

![スクリーンショット 2022-12-29 17.40.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227279/399a6032-a71d-2515-df60-99476542157a.png)

# プレミアム会員になる

## プレミアム会員詳細画面を作成

1. `rails g controller subscription`を実行
2. routes.rbに、`resource :subscription, only: %i[show new destroy]`を追加
3. subscriptions/show.html.hamlを作成
    ```haml:show.html.haml
    %h1.page-title プレミアム会員
    
    .row.justify-content-center
      .card.col-11.col-md-8.col-lg-6
        .card-body
          %h2.fs-4.text-primary １ヶ月 #{current_user.stripe_amount}円
          .d-grid= link_to 'プレミアム会員になる', new_subscription_path, class: 'btn btn-primary'
    ```
    
    `current_user.stripe_amount`は、上記で作成した商品の価格を取得するメソッドである。
    ```rb:user.rb
    class User < ApplicationRecord
    
      STRIPE_PRICE_ID = Rails.application.credentials.stripe.price_id
    
      def stripe_amount
        stripe_plan[:amount]
      end    
      
      private
    
      def stripe_plan
        @stripe_plan ||= Stripe::Plan.retrieve(STRIPE_PRICE_ID)
      end  
    end
    ```


## プレミアム会員になるための画面を作成([参照](https://stripe.com/docs/billing/subscriptions/build-subscriptions?ui=elements#create-pricing-model))

1. `SubscriptionController#new`で、`current_user`に対してStripe上の顧客が作成されていなければ作成する。サブスクリプションが作成されていなければ作成する。
    ```rb:subscriptions_controller.rb
    class SubscriptionsController < ApplicationController
      def new
        current_user.create_stripe_customer! if current_user.customer_key.blank?
        current_user.create_stripe_subscription! if current_user.subscription_key.blank?
      end
    end
    ```
    `create_stripe_customer!`メソッドで、`current_user`に対応するstripe上の顧客を作成している。`Stripe::Customer.create`で`idempotency_key`を指定することで、`current_user`に対して複数の顧客が作成されないようにしている。
    ```rb:user.rb
    class User < ApplicationRecord
      def create_stripe_customer!
        return if customer_key.present?
    
        idempotency_key = Digest::SHA1.hexdigest("user_#{id}_customer")
        @stripe_customer = Stripe::Customer.create({ email: }, { idempotency_key: })
        update!(customer_key: @stripe_customer.id)
        @stripe_customer
      end    
    end
    ```
    `create_stripe_subscription!`メソッドで、`current_user`に対応するstripe上のサブスクリプションを作成している。`payment_behavior: 'default_incomplete'`と設定することで、サブスクリプション作成時のデフォルトのステータスが`incomplete`になる。初めての支払いに成功した時に、ステータスが`active`になる。`payment_settings: { save_default_payment_method: 'on_subscription' }`と設定し支払いが成功すると、その時の支払い方法がデフォルトになる。
    ```rb:user.rb
    class User < ApplicationRecord    
      def create_stripe_subscription!
        return if customer_key.blank? || subscription_key.present?
    
        @stripe_subscription = Stripe::Subscription.create(
          customer: stripe_customer.id,
          items: [{ price: STRIPE_PRICE_ID }],
          payment_behavior: 'default_incomplete',
          payment_settings: { save_default_payment_method: 'on_subscription' },
          expand: ['latest_invoice.payment_intent']
        )
        update!(subscription_key: @stripe_subscription.id, subscription_status: @stripe_subscription.status)
        @stripe_client_secret = @stripe_subscription.latest_invoice.payment_intent.client_secret
        @stripe_subscription
      end

      def stripe_customer
        return nil if customer_key.blank?
    
        @stripe_customer ||= Stripe::Customer.retrieve(customer_key)
      end      
    end
    ```
2. クレジットカード情報を入力して、サブスクリプションの支払い方法を登録できるようにする
    ```haml:subscriptions/new.html.haml
    %h1.page-title プレミアム会員になる
    
    .row.justify-content-center
      .card.col-11.col-md-8.col-lg-6
        .card-body
          %form{ data: { controller: 'payment-form',
                         action: 'payment-form#submit',
                         payment_form: { stripe_key_value: Rails.application.credentials.stripe.public_key,
                                         client_secret_value: current_user.stripe_client_secret,
                                         complete_url_value: subscription_url,} },
                         turbo_confirm: '本当にプレミアム会員になりますか？' }
            .d-none.text-danger.mb-2{ data: { payment_form_target: 'error' } }
            .mb-5{ data: { payment_form_target: 'element' } }
            %button.btn.btn-primary.w-100.d-block{ disabled: true,  data: { payment_form_target: 'submit' } }
              プレミアム会員になる
    ```

    `current_user.stripe_client_secret`メソッドは、上記で作成したサブスクリプションに関連する`client_secret`を取得するものである。


    ```rb:user.rb
    class User < ApplicationRecord
      def stripe_client_secret
        return nil if subscription_key.blank?
        return @stripe_client_secret if @stripe_client_secret.present?
    
        latest_invoice = Stripe::Invoice.retrieve(stripe_subscription.latest_invoice)
        payment_intent = Stripe::PaymentIntent.retrieve(latest_invoice.payment_intent)
        @stripe_client_secret ||= payment_intent.client_secret
      end    
    end
    ```


    stimulusで、クレジットカード情報を入力して、サブスクリプションの支払い方法を登録できるようにする。


    ```js:payment_form_controller.js
    import { Controller } from '@hotwired/stimulus'
    import { loadStripe } from '@stripe/stripe-js/pure'
    
    export default class extends Controller {
      static targets = ['element', 'submit', 'error']
      static values = { stripeKey: String, clientSecret: String, completeUrl: String }
    
      connect() {
        this._mountStripeElement()
      }
    
      disconnect() {
        this.paymentElement?.destroy()
      }
    
      async submit(event) {
        event.preventDefault()
    
        const { error } = await this.stripe.confirmPayment({
          elements: this.elements,
          confirmParams: {
            return_url: this.completeUrlValue
          }
        })
    
        this._showError(error.message)
        this.submitTarget.removeAttribute('disabled')
      }
    
      async _mountStripeElement() {
        this.stripe = await loadStripe(this.stripeKeyValue)
        this.elements = this.stripe.elements({ loader: 'always', clientSecret: this.clientSecretValue })
        this.paymentElement = this.elements.create('payment')
        this.paymentElement.mount(this.elementTarget)
        this.paymentElement.on('ready', () => {
          this.submitTarget.removeAttribute('disabled')
        })
      }
    
      _showError(message) {
        this.errorTarget.textContent = message
        this.errorTarget.classList.remove('d-none')
      }
    }
    ```
     `this.stripe.confirmPayment`メソッドで、クレジットカード情報をもとに支払い処理を行う。

3. 支払い処理のステータスをフラッシュメッセージで表示する
    ```rb:subscriptions_controller.rb
    class SubscriptionsController < ApplicationController
      before_action :set_stripe_flash_message, only: :show, if: -> { params[:payment_intent].present? }
    
      private
    
      def set_stripe_flash_message
        stripe_payment_intent = Stripe::PaymentIntent.retrieve(params[:payment_intent])
        case stripe_payment_intent.status
        when 'succeeded' then flash.now[:notice] = '支払いに成功しました。あなたはプレミアム会員です。'
        when 'processing' then flash.now[:notice] = '支払い処理中です。それが終われば、あなたはプレミアム会員です。'
        when 'requires_payment_method' then flash.now[:danger] = '支払いに失敗しました。もう一度、支払い手続きを行ってください。'
        else flash.now[:danger] = '何らかの問題がおきました。'
        end
      end
    end    
    ```
   支払い処理のステータスは`this.completeUrlValue`にリダイレクトされたときのクエリパラメータ`payment_intent`を元にして取得できる。

## プレミアム会員の有効期限を表示する
1. `rails g model stripe_webhook body:json`を実行。
    このモデルに、stripeのwebhookイベントを保存する。
2. `rails g controller stripe_webhook`を実行
3. routes.rbに、`resource :stripe_webhook, only: :create`を追加
4. https://dashboard.stripe.com/test/webhooks/create で、webhookを受信するエンドポイント作成
5. webhookエンドポイントに送信されるwebhookイベントをDBに保存する
    ```rb:stripe_webhooks_controller.rb
    class StripeWebhooksController < ApplicationController
      skip_before_action :verify_authenticity_token
      skip_before_action :authenticate_user!
      before_action :set_webhook_event_data
    
      def create
        StripeWebhook.create!(body: @event)
        head :ok
      end
    
      private
    
      def set_webhook_event_data
        event = nil
        begin
          payload = request.body.read
          sig_header = request.env['HTTP_STRIPE_SIGNATURE']
          event = Stripe::Webhook.construct_event(
            payload, sig_header, Rails.application.credentials.stripe.fetch(:webhook_secret)
          )
        rescue JSON::ParserError, Stripe::SignatureVerificationError => e
          head :bad_request
          return
        end
    
        @event = event
      end
    end
    ```
6. StripeWebhookモデルの情報がコミットされた後に、Webhookイベントに対する処理を実行させる
    ```rb:stripe_webhook.rb
    class StripeWebhook < ApplicationRecord
      validates :body, presence: true
    
      after_commit :react_to_self
    
      private
    
      def react_to_self
        case body['type']
        when 'customer.subscription.updated'
          if user&.subscription_key.present? && user&.subscription_status.present?
            if data['status'] == 'incomplete_expired'
              user.update!(subscription_key: nil, subscription_status: nil)
            else
              user.update!(subscription_status: data['status'])
            end
          end
        when 'invoice.payment_succeeded'
          Stripe::Subscription.update(data['subscription'], { billing_cycle_anchor: 'now', proration_behavior: 'none' })
        end
      end
    
      def data
        @data ||= body.dig('data', 'object')
      end
    
      def user
        @user ||= User.find_by(customer_key: data['customer'])
      end
    end
    ```
    イベントのタイプが`customer.subscription.updated`である時、ユーザーにひもづくサブスクリプションのステータスを更新する。ステータスが`incomplete_expired`になっているときは、そのサブスクリプションのステータスはそれ以上変更できない。つまり、`active`にできない。そこから、プレミアム会員になるには、新たにサブスクリプションを作成する必要がある。このことから、`subscription_key, subscription_status`の両方とも`nil`に更新している。

    イベントのタイプが`invoice.payment_succeeded`である時は、請求日時を現在にするように更新している。サブスクリプションに対する支払い処理が成功した時から、サブスクリプションの有効期間が始まるようにするためにそうしている。なお、`proration_behavior: 'none'`にすることで、支払い処理が済んでいない間の利用料金を請求しないようにしている。

7. プレミアム会員の有効期限を表示する
    ```haml:subscriptions/show.html.haml
    %h1.page-title プレミアム会員
    
    .row.justify-content-center
      .card.col-11.col-md-8.col-lg-6
        .card-body
          %h2.fs-4.text-primary １ヶ月 #{current_user.stripe_amount}円
          - current_user.reload # webhook eventによるsubscription statusの更新を反映させるため
          - if current_user.stripe_subscription.present? && current_user.subscription_status.active?
            %p プレミアム会員(#{l(Time.zone.at(current_user.stripe_subscription.current_period_start), format: :long)}-#{l(Time.zone.at(current_user.stripe_subscription.current_period_end), format: :long)})
          - else
            .d-grid= link_to 'プレミアム会員になる', new_subscription_path, class: 'btn btn-primary'
    ```

# プレミアム会員を退会する

1. 「プレミアム会員を退会する」ボタンを追加する
    ```haml:subscriptions/show.html.haml
    %h1.page-title プレミアム会員
    
    .row.justify-content-center
      .card.col-11.col-md-8.col-lg-6
        .card-body
          %h2.fs-4.text-primary １ヶ月 #{current_user.stripe_amount}円
          - current_user.reload # webhook eventによるsubscription statusの更新を反映させるため
          - if current_user.stripe_subscription.present? && current_user.subscription_status.active?
            %p プレミアム会員(#{l(Time.zone.at(current_user.stripe_subscription.current_period_start), format: :long)}-#{l(Time.zone.at(current_user.stripe_subscription.current_period_end), format: :long)})
            .d-grid= button_to 'プレミアム会員を退会する', subscription_path,
                  { method: :delete, form: { data: { turbo_confirm: '本当にプレミアム会員をやめますか？' } }, class: 'btn btn-danger w-100'}
          - else
            .d-grid= link_to 'プレミアム会員になる', new_subscription_path, class: 'btn btn-primary'
    ```

2. subscription_controllerにdestroyアクションを追加
    ```rb:subscriptions_controller.rb
    class SubscriptionsController < ApplicationController
      def destroy
        stripe_subscription = Stripe::Subscription.cancel(current_user.subscription_key)
        if stripe_subscription.status == 'canceled'
          current_user.update!(subscription_key: nil, subscription_status: nil)
          redirect_to subscription_path, notice: 'プレミアム会員を退会できました'
        else
          redirect_to subscription_path, danger: 'プレミアム会員を退会できませんでした'
        end
      end
    end
    ```