stripeを使って、サブスクリプション機能に関する以下のユースケースを実装してみた。
- プレミアム会員になる
- プレミアム会員を退会する
前提条件
- stripeアカウントを持っている
- 以下のgemをGemfileに追加している
gem 'devise'
gem 'haml-rails'
gem 'stripe'
- 
devisegemにより、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 endcreate_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 endcreate_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 endstimulusで、クレジットカード情報を入力して、サブスクリプションの支払い方法を登録できるようにする。 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


