stripeを使って、サブスクリプション機能に関する以下のユースケースを実装してみた。
- プレミアム会員になる
- プレミアム会員を退会する
前提条件
- stripeアカウントを持っている
- 以下のgemを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上のサブスクリプションの状態を表す属性
- customer_key:string
- package.jsonに
@stripe/stripe-js
が追加されている
stripeダッシュボードで商品を追加する
以下にアクセスして、商品を作成する(入力情報は以下の画像を参考)。
https://dashboard.stripe.com/test/products/create
プレミアム会員になる
プレミアム会員詳細画面を作成
-
rails g controller subscription
を実行 -
routes.rbに、
resource :subscription, only: %i[show new destroy]
を追加 -
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.rbclass 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
プレミアム会員になるための画面を作成(参照)
-
SubscriptionController#new
で、current_user
に対してStripe上の顧客が作成されていなければ作成する。サブスクリプションが作成されていなければ作成する。subscriptions_controller.rbclass 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
に対して複数の顧客が作成されないようにしている。user.rbclass 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.rbclass 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
-
クレジットカード情報を入力して、サブスクリプションの支払い方法を登録できるようにする
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.rbclass 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.jsimport { 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
メソッドで、クレジットカード情報をもとに支払い処理を行う。 -
支払い処理のステータスをフラッシュメッセージで表示する
subscriptions_controller.rbclass 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
を元にして取得できる。
プレミアム会員の有効期限を表示する
-
rails g model stripe_webhook body:json
を実行。
このモデルに、stripeのwebhookイベントを保存する。 -
rails g controller stripe_webhook
を実行 -
routes.rbに、
resource :stripe_webhook, only: :create
を追加 -
https://dashboard.stripe.com/test/webhooks/create で、webhookを受信するエンドポイント作成
-
webhookエンドポイントに送信されるwebhookイベントをDBに保存する
stripe_webhooks_controller.rbclass 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
-
StripeWebhookモデルの情報がコミットされた後に、Webhookイベントに対する処理を実行させる
stripe_webhook.rbclass 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'
にすることで、支払い処理が済んでいない間の利用料金を請求しないようにしている。 -
プレミアム会員の有効期限を表示する
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'
プレミアム会員を退会する
-
「プレミアム会員を退会する」ボタンを追加する
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'
-
subscription_controllerにdestroyアクションを追加
subscriptions_controller.rbclass 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