LoginSignup
0

posted at

updated at

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

stripeを使って、サブスクリプション機能に関する以下のユースケースを実装してみた。

  • プレミアム会員になる
  • プレミアム会員を退会する

overview.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

スクリーンショット 2022-12-29 17.40.02.png

プレミアム会員になる

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

  1. rails g controller subscriptionを実行

  2. routes.rbに、resource :subscription, only: %i[show new destroy]を追加

  3. subscriptions/show.html.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 1ヶ月 #{current_user.stripe_amount}.d-grid= link_to 'プレミアム会員になる', new_subscription_path, class: 'btn btn-primary'
    

    current_user.stripe_amountは、上記で作成した商品の価格を取得するメソッドである。

    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
    

プレミアム会員になるための画面を作成(参照)

  1. SubscriptionController#newで、current_userに対してStripe上の顧客が作成されていなければ作成する。サブスクリプションが作成されていなければ作成する。

    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.createidempotency_keyを指定することで、current_userに対して複数の顧客が作成されないようにしている。

    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' }と設定し支払いが成功すると、その時の支払い方法がデフォルトになる。

    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. クレジットカード情報を入力して、サブスクリプションの支払い方法を登録できるようにする

    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を取得するものである。

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

    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. 支払い処理のステータスをフラッシュメッセージで表示する

    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に保存する

    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イベントに対する処理を実行させる

    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. プレミアム会員の有効期限を表示する

    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 1ヶ月 #{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. 「プレミアム会員を退会する」ボタンを追加する

    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 1ヶ月 #{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アクションを追加

    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
    

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
0